@muretai/agent-entry 1.6.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +174 -19
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -111,7 +111,9 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
111
111
  | `domains` | none | the domains this entry speaks for (see below) |
112
112
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
113
113
  | `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*). Recognition only adds `env.wba_did` and a visit count; it never changes a verdict |
114
- | `name`, `description`, `version` | | the card's own words. `description` is the line a person reads in a directory listing |
114
+ | `observer` | none | called once per message with the same envelope your responder gets, **after** the verdict — for counting, logging, analytics. It cannot matter: its return is discarded, a throw is swallowed, a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. See [Counting visits](#counting-visits-without-handing-over-who-they-are) |
115
+ | `howToUrl` | none | a page a keyless visitor is pointed at as a worked example. **Empty means omitted** — the refusal already teaches the whole recipe without it, and a reference implementation must not stamp somebody else's docs host into every door built from it. Only set it to a URL you operate, and only after checking it resolves |
116
+ | `name`, `description`, `version` | — | the card's own words. `description` is the line a person reads in a directory listing — and the right place to say what you record about visitors, since it is fetched **before** the knock |
115
117
 
116
118
  `seedHex` and `baseUrl` are the two an entry refuses to start without: the seed **is** the
117
119
  address, and the url it publishes must equal the origin the visitor dialled.
@@ -198,7 +200,91 @@ cannot be told to look elsewhere:
198
200
  One round trip. No callback, no webhook, nothing to keep awake.
199
201
 
200
202
  `POST /` is exact — a POST anywhere else is 404. But **`GET /` is not taken**, so your home
201
- page stays exactly as it is. A site gives up three routes and nothing else.
203
+ page stays exactly as it is.
204
+
205
+ ### The fourth step, and it is not optional
206
+
207
+ Three routes make the door **work**. They do not make it **findable**, and those are separate
208
+ problems with separate fixes.
209
+
210
+ A visiting agent knows your domain, so it can guess the card path — but only if something told
211
+ it there is an agent here at all. Normally that something is this module's own `GET /` notice.
212
+ **If your pages are served by a different process than the door — a CDN, a static host, a
213
+ framework, an edge worker — that notice never renders**, and your home page is HTML written for
214
+ people with nothing machine-readable in it. The address ends up published in a card nobody was
215
+ told to fetch.
216
+
217
+ So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
218
+ fallback for the other:
219
+
220
+ ```
221
+ Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
222
+ ```
223
+
224
+ ```html
225
+ <link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
226
+ ```
227
+
228
+ The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
229
+ endpoint requires a request to that host. The two spellings exist because the two kinds of client
230
+ have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
231
+ header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
232
+ which kind arrived.
233
+
234
+ We know because we shipped one. An agent that had never been told about our door was handed only
235
+ the domain, fetched the page, read the copy written for humans, and stopped — while the door had
236
+ been answering signed messages correctly the whole time, at the address on that very page.
237
+
238
+ Then check it from outside, because this is exactly the class of thing that looks installed:
239
+
240
+ ```bash
241
+ curl -sI https://studio.example/ | grep -i '^link:' # the header half
242
+ curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
243
+ ```
244
+
245
+ Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
246
+ to markdown**, which is a common way an agent reads the web — headers are dropped and so is
247
+ everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
248
+ that agents are answered here, and name the card path in text a reader can act on.
249
+
250
+ ### Check that your own CDN is not refusing your door
251
+
252
+ The failure you are least likely to look for, because everything you control is correct.
253
+
254
+ Most sites sit behind something that turns away suspicious traffic, and much of that judging is
255
+ done on the **User-Agent** — which a client writes about itself, so the honest defaults are what
256
+ get caught. Ours refused the default agent Python's standard library sends, and not only on the
257
+ home page: on the **card** and on `POST /` too. The door was published, correct, and answering —
258
+ to nobody using the stdlib client that "zero dependencies" produces.
259
+
260
+ **The tell is the body of the refusal.** A door refuses in JSON and says how to qualify. An
261
+ intermediary refuses in a line of plain text — `error code: 1010`, seventeen bytes, no `Link`, no
262
+ card path, nothing a visitor can act on. If that is what strangers get, the door never saw them.
263
+
264
+ **Do not check with `curl`.** It sends its own agent string and sails through, so "reproduce it
265
+ with curl" turns a broken door into evidence that the visitor is at fault. Use a plain
266
+ standard-library client, from outside your network:
267
+
268
+ ```bash
269
+ UA='Python-urllib/3.11' # or your language's default — the point is that it IS the default
270
+ curl -s -A "$UA" -X POST https://studio.example/ -H 'content-type: application/json' \
271
+ -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}' | head -c 80
272
+ ```
273
+
274
+ That must come back as JSON. Anything else is your edge, not your entry.
275
+
276
+ **The exemption is simpler than it looks, and its shape is the point.** You never have to ask
277
+ your CDN whether a caller is a bot — only to name three things it already knows: **host, method,
278
+ path.** Because this door partitions by method, `POST /` and the card paths are exactly the
279
+ surface to exempt, and your pages keep whatever protection they have. Write the rule with no
280
+ user-agent field in it at all — the same rule the door lives by, one layer out.
281
+
282
+ Two limits worth stating plainly. Some protections cannot be exempted by any rule at any tier;
283
+ find out which yours is before promising yourself a carve-out. And **never let your CDN tell your
284
+ responder who it is talking to** — some will forward a bot score or a "verified" flag to your
285
+ origin, and if your origin is reachable without going through them (most are), that header is
286
+ written by whoever dials it directly. Authority is the signature on the message; nothing else
287
+ gets a vote.
202
288
 
203
289
  ### 1. A subdomain — the existing site is untouched
204
290
 
@@ -441,23 +527,14 @@ and it is not one. It is the upgrade path:
441
527
  can carry them.
442
528
  - **Statistics without a store: an analytics sink.** Nothing in the entry reads the
443
529
  ledger back to gate, greet or rate-limit, so a fire-and-forget sink records visiting
444
- agents with no database anywhere. Your `responder` is handed the account DID; Google
445
- Analytics 4 over the Measurement Protocol is one `fetch` inside it:
446
-
447
- ```js
448
- fetch('https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX'
449
- + '&api_secret=' + process.env.GA_API_SECRET, {
450
- method: 'POST',
451
- body: JSON.stringify({ client_id: env.owner_did || env.peer_did,
452
- events: [{ name: 'agent_contact' }] }),
453
- }).catch(() => {}); // analytics must never block a reply
454
- ```
455
-
456
- Keyed by `client_id`, GA tells new from returning visitors by itself — and a DID is a
457
- public key, not personal data, though the record then lives with a third party, which
458
- is your call. A sink cannot be read back during a request: it counts customers, it
459
- cannot recognise one. It replaces a log line, not the store above — none of the
460
- recommended features stand on it.
530
+ agents with no database anywhere. Use `observer` for it, never your responder: watching
531
+ a visit should not be an edit to the code that decides what to say. See
532
+ [Counting visits](#counting-visits-without-handing-over-who-they-are) below for the
533
+ whole pattern, including the one rule that shapes it — **a DID is not a page view**,
534
+ so what leaves your box is a salted digest, never the identifier itself. A sink cannot
535
+ be read back during a request: it counts customers, it cannot recognise one. It
536
+ replaces a log line, not the store above — none of the recommended features stand
537
+ on it.
461
538
  - **Revocation reaches you through your backend, not through this file.** An Agent Entry
462
539
  is deliberately network-free on the hot path: it never dials out while answering a
463
540
  visitor. Bindings carry an expiry, and a full node checks published revocations within
@@ -483,6 +560,84 @@ What the entry now handles for you at the HTTP layer, so you do not have to:
483
560
  which says a server must accept the absolute form: this endpoint answers exactly the
484
561
  address its card names, and the refusal says so.
485
562
 
563
+ ## Counting visits without handing over who they are
564
+
565
+ You will want to know how many agents knocked, how many came back, and what they asked. All
566
+ three are answerable — and how you answer them decides whether you are counting your visitors
567
+ or contributing to a profile of them.
568
+
569
+ **Use `observer`, not your responder.** The door calls it once per message with the same
570
+ envelope, after the verdict, so watching a visit stops being an edit to the code that decides
571
+ what to say. It cannot matter: its return is discarded, a throw is swallowed, a promise is
572
+ never awaited — a slow or broken watcher cannot delay or change one byte of the signed reply.
573
+
574
+ **The rule that shapes everything else: a DID is not a page view.** A visitor hands you one in
575
+ order to transact with **you**, and here first contact *is* the account — there is no signup
576
+ form where they agreed to anything else. Forwarding the raw value to an analytics vendor shares
577
+ a durable identifier its owner never offered them, silently, on a surface with no consent
578
+ dialog and no visitor who could decline. So split it:
579
+
580
+ - **What leaves** — a salted digest and a few shape facts. Never the DID, never the text.
581
+ - **What stays** — the relationship (who, how many, first and last seen) in your own store,
582
+ which is the only place it was ever offered to.
583
+
584
+ **Salt the digest, and treat the salt as a secret.** A bare `sha256(did)` is a *stable global*
585
+ pseudonym: anyone else who hashes the same DID gets the same string, so two properties could
586
+ join their records on it. An HMAC under a secret only you hold makes the pseudonym meaningless
587
+ anywhere else — the whole difference between "we count returning visitors" and "we helped build
588
+ a profile".
589
+
590
+ Google Analytics 4 over the Measurement Protocol, as an example of any sink:
591
+
592
+ ```js
593
+ import crypto from 'node:crypto';
594
+
595
+ const pseudonym = (did) =>
596
+ crypto.createHmac('sha256', process.env.PSEUDONYM_SALT).update(did).digest('hex').slice(0, 32);
597
+
598
+ const observer = (env) => {
599
+ const account = env.owner_did || env.peer_did;
600
+ if (!account) return; // an unsigned walk-in is traffic, not a visitor
601
+ const first = (entry.ledger.get(account)?.messages ?? 1) === 1;
602
+
603
+ // `client_id` is the pseudonym, so the vendor can tell a returning visitor from a new one
604
+ // WITHOUT ever holding the DID that distinguishes them.
605
+ fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`, {
606
+ method: 'POST',
607
+ body: JSON.stringify({
608
+ client_id: pseudonym(account),
609
+ non_personalized_ads: true,
610
+ events: [{ name: 'agent_knock', params: { verified: env.verified ? 1 : 0,
611
+ first_contact: first ? 1 : 0,
612
+ intent: classify(env.text) }}],
613
+ }),
614
+ }).catch(() => {}); // a dropped metric, never a dropped answer
615
+ };
616
+ ```
617
+
618
+ Four details there are load-bearing:
619
+
620
+ - **`classify(env.text)`, never `env.text`.** Send *your own* bounded label, not what a stranger
621
+ typed. An attacker-chosen string must never become a dimension in your analytics.
622
+ - **`.catch(() => {})` and no `await`.** Your door answers in one round trip; nothing on that
623
+ path may wait on somebody else's uptime. The `observer` contract already guarantees this — do
624
+ not lean on that generosity to be correct.
625
+ - **Give it a timeout too** (an `AbortController` at a second or two). A hung connection is not
626
+ an error, so `catch` alone never fires.
627
+ - **Say at boot whether the sink is on.** A sink silently off because a secret was never set
628
+ looks exactly like a sink that is on and receiving nothing, and a dashboard reading zero
629
+ cannot tell you which.
630
+
631
+ **Say it on the card, because that is the surface your visitor reads.** Whatever you record, the
632
+ party whose identifier it is arrives as an agent and will never open a privacy page written for
633
+ people. Your card is fetched *before* the knock — that is the point of publishing terms up front
634
+ — so it is the one place a visitor can learn what happens to its DID and still decide not to
635
+ knock. Two or three sentences in `description`: what you keep, what leaves, what never does. A
636
+ disclosure that arrives after the visit is not a disclosure, it is a receipt.
637
+
638
+ And if you decide to send raw DIDs anyway, that is your call to make — but say so on the card,
639
+ in the same breath, in plain words.
640
+
486
641
  ## Two implementations, pinned to each other
487
642
 
488
643
  This module is not alone. A Python reference implements the same contract, and the two are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Make your website answer AI agents: an A2A agent endpoint that verifies who is knocking, opens an account for them and replies signed, in one HTTP round trip. Zero dependencies. Pairs with llms.txt and WebMCP.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",