@muretai/agent-entry 1.6.0 → 1.6.2
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.
- package/README.md +194 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,14 +104,19 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
|
|
|
104
104
|
| option | default | what it does |
|
|
105
105
|
|---|---|---|
|
|
106
106
|
| `skills` | `[]` | the menu above — what a visitor learns before knocking |
|
|
107
|
-
| `openDoor` | `true` | publishes `
|
|
107
|
+
| `openDoor` | `true` | publishes `agentEntry.open_door`: the field that tells a visiting agent it may message you with no introduction. The same fact is emitted under the older `muretai.open_door` spelling beside it — read either, write the neutral one |
|
|
108
108
|
| `anonymousLane` | `false` | also answer **unsigned** inquiries. They create no account row, and the lane is capped entry-wide — an unauthenticated caller must never become an unmetered signing oracle |
|
|
109
|
-
| `anonRatePerMin` | `30` | anonymous replies per minute, entry-wide
|
|
109
|
+
| `anonRatePerMin` | `30` | anonymous replies per minute, entry-wide |
|
|
110
|
+
| `signedRatePerMin` | `60` | signed replies per minute **per account**, ON by default. Attribution is not scarcity: a `did:key` costs nothing to mint, so being in your ledger was never a bound |
|
|
111
|
+
| `signedRatePerMinTotal` | `600` | signed replies per minute for the **whole entry**. Free identity defeats per-identity metering by definition, so only the aggregate resists a flood |
|
|
112
|
+
| `guest` | `false` | put the door on a path of its own and leave `GET /` alone entirely — for a site that is keeping its front page. A `GET` on a guest mount answers **405 with `Allow: POST, OPTIONS`**, never 404: the address is signed into a public card, and hiding a published address conceals nothing |
|
|
110
113
|
| `maxAccounts` | `50000` | how many accounts the in-process ledger holds |
|
|
111
114
|
| `domains` | none | the domains this entry speaks for (see below) |
|
|
112
115
|
| `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
|
|
113
116
|
| `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
|
-
| `
|
|
117
|
+
| `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) |
|
|
118
|
+
| `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 |
|
|
119
|
+
| `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
120
|
|
|
116
121
|
`seedHex` and `baseUrl` are the two an entry refuses to start without: the seed **is** the
|
|
117
122
|
address, and the url it publishes must equal the origin the visitor dialled.
|
|
@@ -198,7 +203,91 @@ cannot be told to look elsewhere:
|
|
|
198
203
|
One round trip. No callback, no webhook, nothing to keep awake.
|
|
199
204
|
|
|
200
205
|
`POST /` is exact — a POST anywhere else is 404. But **`GET /` is not taken**, so your home
|
|
201
|
-
page stays exactly as it is.
|
|
206
|
+
page stays exactly as it is.
|
|
207
|
+
|
|
208
|
+
### The fourth step, and it is not optional
|
|
209
|
+
|
|
210
|
+
Three routes make the door **work**. They do not make it **findable**, and those are separate
|
|
211
|
+
problems with separate fixes.
|
|
212
|
+
|
|
213
|
+
A visiting agent knows your domain, so it can guess the card path — but only if something told
|
|
214
|
+
it there is an agent here at all. Normally that something is this module's own `GET /` notice.
|
|
215
|
+
**If your pages are served by a different process than the door — a CDN, a static host, a
|
|
216
|
+
framework, an edge worker — that notice never renders**, and your home page is HTML written for
|
|
217
|
+
people with nothing machine-readable in it. The address ends up published in a card nobody was
|
|
218
|
+
told to fetch.
|
|
219
|
+
|
|
220
|
+
So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
|
|
221
|
+
fallback for the other:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```html
|
|
228
|
+
<link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
|
|
232
|
+
endpoint requires a request to that host. The two spellings exist because the two kinds of client
|
|
233
|
+
have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
|
|
234
|
+
header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
|
|
235
|
+
which kind arrived.
|
|
236
|
+
|
|
237
|
+
We know because we shipped one. An agent that had never been told about our door was handed only
|
|
238
|
+
the domain, fetched the page, read the copy written for humans, and stopped — while the door had
|
|
239
|
+
been answering signed messages correctly the whole time, at the address on that very page.
|
|
240
|
+
|
|
241
|
+
Then check it from outside, because this is exactly the class of thing that looks installed:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
curl -sI https://studio.example/ | grep -i '^link:' # the header half
|
|
245
|
+
curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
|
|
249
|
+
to markdown**, which is a common way an agent reads the web — headers are dropped and so is
|
|
250
|
+
everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
|
|
251
|
+
that agents are answered here, and name the card path in text a reader can act on.
|
|
252
|
+
|
|
253
|
+
### Check that your own CDN is not refusing your door
|
|
254
|
+
|
|
255
|
+
The failure you are least likely to look for, because everything you control is correct.
|
|
256
|
+
|
|
257
|
+
Most sites sit behind something that turns away suspicious traffic, and much of that judging is
|
|
258
|
+
done on the **User-Agent** — which a client writes about itself, so the honest defaults are what
|
|
259
|
+
get caught. Ours refused the default agent Python's standard library sends, and not only on the
|
|
260
|
+
home page: on the **card** and on `POST /` too. The door was published, correct, and answering —
|
|
261
|
+
to nobody using the stdlib client that "zero dependencies" produces.
|
|
262
|
+
|
|
263
|
+
**The tell is the body of the refusal.** A door refuses in JSON and says how to qualify. An
|
|
264
|
+
intermediary refuses in a line of plain text — `error code: 1010`, seventeen bytes, no `Link`, no
|
|
265
|
+
card path, nothing a visitor can act on. If that is what strangers get, the door never saw them.
|
|
266
|
+
|
|
267
|
+
**Do not check with `curl`.** It sends its own agent string and sails through, so "reproduce it
|
|
268
|
+
with curl" turns a broken door into evidence that the visitor is at fault. Use a plain
|
|
269
|
+
standard-library client, from outside your network:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
UA='Python-urllib/3.11' # or your language's default — the point is that it IS the default
|
|
273
|
+
curl -s -A "$UA" -X POST https://studio.example/ -H 'content-type: application/json' \
|
|
274
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}' | head -c 80
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
That must come back as JSON. Anything else is your edge, not your entry.
|
|
278
|
+
|
|
279
|
+
**The exemption is simpler than it looks, and its shape is the point.** You never have to ask
|
|
280
|
+
your CDN whether a caller is a bot — only to name three things it already knows: **host, method,
|
|
281
|
+
path.** Because this door partitions by method, `POST /` and the card paths are exactly the
|
|
282
|
+
surface to exempt, and your pages keep whatever protection they have. Write the rule with no
|
|
283
|
+
user-agent field in it at all — the same rule the door lives by, one layer out.
|
|
284
|
+
|
|
285
|
+
Two limits worth stating plainly. Some protections cannot be exempted by any rule at any tier;
|
|
286
|
+
find out which yours is before promising yourself a carve-out. And **never let your CDN tell your
|
|
287
|
+
responder who it is talking to** — some will forward a bot score or a "verified" flag to your
|
|
288
|
+
origin, and if your origin is reachable without going through them (most are), that header is
|
|
289
|
+
written by whoever dials it directly. Authority is the signature on the message; nothing else
|
|
290
|
+
gets a vote.
|
|
202
291
|
|
|
203
292
|
### 1. A subdomain — the existing site is untouched
|
|
204
293
|
|
|
@@ -412,8 +501,10 @@ navigator.modelContext.registerTool({
|
|
|
412
501
|
```
|
|
413
502
|
|
|
414
503
|
`MY_DID` is the DID your Agent Entry prints at startup — **the same one**, from the same seed.
|
|
415
|
-
That is the only rule when running both
|
|
416
|
-
|
|
504
|
+
That is the only rule when running both, and **nothing here enforces it** — this package never
|
|
505
|
+
sees your in-page handoff. A mismatch is a site publishing two different identities for one
|
|
506
|
+
origin, which a careful visitor may notice and a careless one will not, so treat it as your
|
|
507
|
+
invariant to keep rather than a guard you are behind.
|
|
417
508
|
|
|
418
509
|
What the shop gets out of it: the moment that signed message arrives, an account exists. No
|
|
419
510
|
signup form, no password, nothing to reset — the sender's key is the account. Come back
|
|
@@ -441,23 +532,14 @@ and it is not one. It is the upgrade path:
|
|
|
441
532
|
can carry them.
|
|
442
533
|
- **Statistics without a store: an analytics sink.** Nothing in the entry reads the
|
|
443
534
|
ledger back to gate, greet or rate-limit, so a fire-and-forget sink records visiting
|
|
444
|
-
agents with no database anywhere.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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.
|
|
535
|
+
agents with no database anywhere. Use `observer` for it, never your responder: watching
|
|
536
|
+
a visit should not be an edit to the code that decides what to say. See
|
|
537
|
+
[Counting visits](#counting-visits-without-handing-over-who-they-are) below for the
|
|
538
|
+
whole pattern, including the one rule that shapes it — **a DID is not a page view**,
|
|
539
|
+
so what leaves your box is a salted digest, never the identifier itself. A sink cannot
|
|
540
|
+
be read back during a request: it counts customers, it cannot recognise one. It
|
|
541
|
+
replaces a log line, not the store above — none of the recommended features stand
|
|
542
|
+
on it.
|
|
461
543
|
- **Revocation reaches you through your backend, not through this file.** An Agent Entry
|
|
462
544
|
is deliberately network-free on the hot path: it never dials out while answering a
|
|
463
545
|
visitor. Bindings carry an expiry, and a full node checks published revocations within
|
|
@@ -483,11 +565,89 @@ What the entry now handles for you at the HTTP layer, so you do not have to:
|
|
|
483
565
|
which says a server must accept the absolute form: this endpoint answers exactly the
|
|
484
566
|
address its card names, and the refusal says so.
|
|
485
567
|
|
|
568
|
+
## Counting visits without handing over who they are
|
|
569
|
+
|
|
570
|
+
You will want to know how many agents knocked, how many came back, and what they asked. All
|
|
571
|
+
three are answerable — and how you answer them decides whether you are counting your visitors
|
|
572
|
+
or contributing to a profile of them.
|
|
573
|
+
|
|
574
|
+
**Use `observer`, not your responder.** The door calls it once per message with the same
|
|
575
|
+
envelope, after the verdict, so watching a visit stops being an edit to the code that decides
|
|
576
|
+
what to say. It cannot matter: its return is discarded, a throw is swallowed, a promise is
|
|
577
|
+
never awaited — a slow or broken watcher cannot delay or change one byte of the signed reply.
|
|
578
|
+
|
|
579
|
+
**The rule that shapes everything else: a DID is not a page view.** A visitor hands you one in
|
|
580
|
+
order to transact with **you**, and here first contact *is* the account — there is no signup
|
|
581
|
+
form where they agreed to anything else. Forwarding the raw value to an analytics vendor shares
|
|
582
|
+
a durable identifier its owner never offered them, silently, on a surface with no consent
|
|
583
|
+
dialog and no visitor who could decline. So split it:
|
|
584
|
+
|
|
585
|
+
- **What leaves** — a salted digest and a few shape facts. Never the DID, never the text.
|
|
586
|
+
- **What stays** — the relationship (who, how many, first and last seen) in your own store,
|
|
587
|
+
which is the only place it was ever offered to.
|
|
588
|
+
|
|
589
|
+
**Salt the digest, and treat the salt as a secret.** A bare `sha256(did)` is a *stable global*
|
|
590
|
+
pseudonym: anyone else who hashes the same DID gets the same string, so two properties could
|
|
591
|
+
join their records on it. An HMAC under a secret only you hold makes the pseudonym meaningless
|
|
592
|
+
anywhere else — the whole difference between "we count returning visitors" and "we helped build
|
|
593
|
+
a profile".
|
|
594
|
+
|
|
595
|
+
Google Analytics 4 over the Measurement Protocol, as an example of any sink:
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
import crypto from 'node:crypto';
|
|
599
|
+
|
|
600
|
+
const pseudonym = (did) =>
|
|
601
|
+
crypto.createHmac('sha256', process.env.PSEUDONYM_SALT).update(did).digest('hex').slice(0, 32);
|
|
602
|
+
|
|
603
|
+
const observer = (env) => {
|
|
604
|
+
const account = env.owner_did || env.peer_did;
|
|
605
|
+
if (!account) return; // an unsigned walk-in is traffic, not a visitor
|
|
606
|
+
const first = (entry.ledger.get(account)?.messages ?? 1) === 1;
|
|
607
|
+
|
|
608
|
+
// `client_id` is the pseudonym, so the vendor can tell a returning visitor from a new one
|
|
609
|
+
// WITHOUT ever holding the DID that distinguishes them.
|
|
610
|
+
fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`, {
|
|
611
|
+
method: 'POST',
|
|
612
|
+
body: JSON.stringify({
|
|
613
|
+
client_id: pseudonym(account),
|
|
614
|
+
non_personalized_ads: true,
|
|
615
|
+
events: [{ name: 'agent_knock', params: { verified: env.verified ? 1 : 0,
|
|
616
|
+
first_contact: first ? 1 : 0,
|
|
617
|
+
intent: classify(env.text) }}],
|
|
618
|
+
}),
|
|
619
|
+
}).catch(() => {}); // a dropped metric, never a dropped answer
|
|
620
|
+
};
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Four details there are load-bearing:
|
|
624
|
+
|
|
625
|
+
- **`classify(env.text)`, never `env.text`.** Send *your own* bounded label, not what a stranger
|
|
626
|
+
typed. An attacker-chosen string must never become a dimension in your analytics.
|
|
627
|
+
- **`.catch(() => {})` and no `await`.** Your door answers in one round trip; nothing on that
|
|
628
|
+
path may wait on somebody else's uptime. The `observer` contract already guarantees this — do
|
|
629
|
+
not lean on that generosity to be correct.
|
|
630
|
+
- **Give it a timeout too** (an `AbortController` at a second or two). A hung connection is not
|
|
631
|
+
an error, so `catch` alone never fires.
|
|
632
|
+
- **Say at boot whether the sink is on.** A sink silently off because a secret was never set
|
|
633
|
+
looks exactly like a sink that is on and receiving nothing, and a dashboard reading zero
|
|
634
|
+
cannot tell you which.
|
|
635
|
+
|
|
636
|
+
**Say it on the card, because that is the surface your visitor reads.** Whatever you record, the
|
|
637
|
+
party whose identifier it is arrives as an agent and will never open a privacy page written for
|
|
638
|
+
people. Your card is fetched *before* the knock — that is the point of publishing terms up front
|
|
639
|
+
— so it is the one place a visitor can learn what happens to its DID and still decide not to
|
|
640
|
+
knock. Two or three sentences in `description`: what you keep, what leaves, what never does. A
|
|
641
|
+
disclosure that arrives after the visit is not a disclosure, it is a receipt.
|
|
642
|
+
|
|
643
|
+
And if you decide to send raw DIDs anyway, that is your call to make — but say so on the card,
|
|
644
|
+
in the same breath, in plain words.
|
|
645
|
+
|
|
486
646
|
## Two implementations, pinned to each other
|
|
487
647
|
|
|
488
648
|
This module is not alone. A Python reference implements the same contract, and the two are
|
|
489
649
|
held to **identical verdicts** by an acceptance suite: it runs the same attack battery
|
|
490
|
-
against both, drives this module against
|
|
650
|
+
against both, drives this module against the same wire vectors byte for byte, posts
|
|
491
651
|
identical bytes to each over real sockets — down to the HTTP framing — and requires the same
|
|
492
652
|
status, the same account outcome and the same signed reply from both. If you write a third
|
|
493
653
|
implementation, that suite is the gate.
|
|
@@ -516,9 +676,16 @@ because a shared library would only ever have covered the parts they happen to s
|
|
|
516
676
|
suite posts identical bytes to both, down to the HTTP framing, and requires the same status,
|
|
517
677
|
the same account outcome and the same signed reply.
|
|
518
678
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
679
|
+
**The part of that gate you can run here ships in this package.** `npm test` executes
|
|
680
|
+
`conformance/run.mjs` against `conformance/vectors.json` — the canonical JSON, the signing
|
|
681
|
+
payloads, the card envelopes and the `did:key` round-trips this module must reproduce byte
|
|
682
|
+
for byte. No network, no checkout of ours, nothing to ask us for:
|
|
683
|
+
|
|
684
|
+
```bash
|
|
685
|
+
npm test
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
Write a third implementation and point it at the same vectors.
|
|
522
689
|
|
|
523
690
|
## What this is part of
|
|
524
691
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
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",
|