@muretai/agent-entry 1.2.1 → 1.4.0

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 CHANGED
@@ -6,7 +6,7 @@ It verifies who is knocking, opens an account for them, and answers — in the s
6
6
  response. No signup form, because the visitor's key already is the account. When that
7
7
  person replaces their phone, your site still knows it is them.
8
8
 
9
- One file. Zero dependencies. Node 20+.
9
+ One file. Zero dependencies. No database. Node 20+.
10
10
 
11
11
  **Running in production — check it yourself, right now:**
12
12
 
@@ -69,6 +69,101 @@ resolves it and files them under `owner_did`, so a replaced phone is not a new c
69
69
  Your entry does not need to poll or be told: a node that carries the account learns it on
70
70
  its own, and refuses that key.
71
71
 
72
+ ## Say what your door answers
73
+
74
+ A visiting agent reads your card **before** it knocks. Left alone, that card says something
75
+ answers here and nothing about what it answers, so the visitor has to guess and learns your
76
+ menu only from whatever comes back when it guesses wrong.
77
+
78
+ ```js
79
+ createAgentEntry({
80
+ seedHex, name: 'Example Studio', baseUrl: 'https://studio.example', responder,
81
+ skills: [{
82
+ id: 'ask',
83
+ name: 'signed-answers-about-the-studio',
84
+ description: 'Ask what a shoot costs, what the studio does, and how to book. '
85
+ + 'The answer comes back in the same HTTP response, signed by this domain.',
86
+ tags: ['studio', 'booking', 'signed', 'inline-reply'],
87
+ examples: ['Do you shoot weddings?', 'What does a half-day cost?', 'How do I book?'],
88
+ }],
89
+ });
90
+ ```
91
+
92
+ It is an A2A `AgentSkill` list, so an agent that already speaks A2A reads it without being
93
+ taught anything new, and it goes into the plain card **and** the signed envelope — the menu
94
+ is signed too.
95
+
96
+ Two rules worth holding yourself to. **Every example must be answerable:** an example is a
97
+ promise printed on your card, and the visitor who copies one verbatim is the best-behaved
98
+ visitor you will get, so drive your examples through your own responder in your tests.
99
+ **Declare only what the responder does:** a skill that mentions booking, on an entry that
100
+ answers questions and hands off nothing, is a signed claim you cannot keep.
101
+
102
+ ### The rest of the settings
103
+
104
+ | option | default | what it does |
105
+ |---|---|---|
106
+ | `skills` | `[]` | the menu above — what a visitor learns before knocking |
107
+ | `openDoor` | `true` | publishes `muretai.open_door`: the field that tells a visiting agent it may message you with no introduction |
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. Signed senders are not bound by it: they are attributable and already in your ledger |
110
+ | `maxAccounts` | `50000` | how many accounts the in-process ledger holds |
111
+ | `domains` | none | the domains this entry speaks for (see below) |
112
+ | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
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 |
115
+
116
+ `seedHex` and `baseUrl` are the two an entry refuses to start without: the seed **is** the
117
+ address, and the url it publishes must equal the origin the visitor dialled.
118
+
119
+ ## Who is knocking — observation, never identity
120
+
121
+ The person who found you often never opens a browser: they hand your link to their
122
+ agent, and the agent fetches your card and knocks. That traffic is invisible to every
123
+ page-view metric you have — the only place it can be seen is the door itself. So the
124
+ door counts it:
125
+
126
+ ```js
127
+ entry.stats()
128
+ // { gptbot: { card_get: 12, signed_post: 3 },
129
+ // browser: { notice_get: 5 } }
130
+ ```
131
+
132
+ Each request's `User-Agent` is classified into a fixed family (`claude-user`,
133
+ `claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
134
+ `curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
135
+ ledger — read it, log it, ship it to your analytics; it is never served on the wire.
136
+ An AI-agent family also gets one nudge: `GET /` answers it with
137
+ `Link: </.well-known/agent-card.json>; rel="service-desc"` (RFC 8631), so a crawler
138
+ that landed on prose is handed the machine-readable door. The body stays
139
+ byte-identical for every caller.
140
+
141
+ One rule holds this together, enforced by the contract suite rather than promised:
142
+ **a User-Agent never affects `verified`, an account row, a rate limit, or any
143
+ refusal.** A UA string is written by the client; a door that trusted it would be a
144
+ door anyone could talk their way through.
145
+
146
+ ### From hint to proof: recognising signed crawlers (Web Bot Auth)
147
+
148
+ Major AI crawlers now **sign** their requests (HTTP Message Signatures, RFC 9421).
149
+ Hand your entry the public keys you trust — the body of a key directory you fetched
150
+ and verified out of band — and it verifies them, with no network call at answer time:
151
+
152
+ ```js
153
+ createAgentEntry({
154
+ seedHex, name, baseUrl, responder,
155
+ wbaVerifiers: { keys: [{ kty: 'OKP', crv: 'Ed25519', x: '…' }] },
156
+ });
157
+ ```
158
+
159
+ A verified fetch is counted (`entry.wbaVisits`); a verified message hands your
160
+ responder `env.wba_did` — the identity whose key signed the *request*, beside
161
+ `env.peer_did`, the identity that signed the *message*. The same rule holds:
162
+ recognition never changes a verdict, mints no account, and lifts no rate limit. A
163
+ signature over the transport proves who fetched — not who wrote the text, and a
164
+ captured header set is replayable until it expires (minutes), which is why `wba_did`
165
+ is identification, never authorship.
166
+
72
167
  ## Install
73
168
 
74
169
  ```bash
@@ -82,6 +177,13 @@ which is the point — you can read all of it before you trust it.
82
177
  curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs
83
178
  ```
84
179
 
180
+ That is the whole footprint. **There is no database to install** and no schema to create —
181
+ an entry runs, in production, on its bounded in-process state, which is how muretai.com's
182
+ own door runs. Once your door is answering, a store of your own is the **recommended**
183
+ upgrade — the ledger is your customer list, and more features stand on keeping it — while
184
+ an analytics tool covers statistics without one. Both are described under
185
+ [Before you put it in production](#before-you-put-it-in-production).
186
+
85
187
  ## Put one on a site you already have
86
188
 
87
189
  A visiting agent knows only your **domain**, so the three paths it walks are fixed — it
@@ -323,12 +425,39 @@ makes the visitor someone you can recognise the next time.
323
425
 
324
426
  ## Before you put it in production
325
427
 
326
- Two things this reference implementation deliberately leaves to you, both called out in
327
- the source:
328
-
329
- - **Persist the ledger and the device→owner pins.** The sample keeps them in memory, so a
330
- restart forgets which owner a device belongs to and trusts the next claim it sees. A
331
- real site puts both in its own database, keyed by exactly the account DID it is handed.
428
+ **Nothing here is needed to start** an entry runs, and every exchange stays correct,
429
+ on its in-process state alone; some installers have read this section as a prerequisite,
430
+ and it is not one. It is the upgrade path:
431
+
432
+ - **Recommended persist the ledger in a store of your own: it is your customer list.**
433
+ Every row is keyed by a customer's DID, which is their address: what you need to
434
+ recognise a returning customer and to contact them again later. In memory that list
435
+ evaporates on restart. Kept in the database your site already has — keyed by exactly
436
+ the account DID you are handed — it is what the features beyond answering stand on:
437
+ greeting a returning account by its history, following up on yesterday's inquiry,
438
+ pricing by relationship. Keep the device→owner pins and the replay guard beside it and
439
+ the security rules — a device is never re-owned, a message is never accepted twice —
440
+ survive restarts as well; those two are read on every message, so only a real store
441
+ can carry them.
442
+ - **Statistics without a store: an analytics sink.** Nothing in the entry reads the
443
+ 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.
332
461
  - **Revocation reaches you through your backend, not through this file.** An Agent Entry
333
462
  is deliberately network-free on the hot path: it never dials out while answering a
334
463
  visitor. Bindings carry an expiry, and a full node checks published revocations within
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * **This is a usage SAMPLE, not part of core Muretai.** It adds nothing to the protocol:
6
6
  * it only wires the public primitive `createAgentEntry()` from
7
- * `web/agent-entry/muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
7
+ * `muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
8
8
  * point it at your backend — core stays byte-unchanged.
9
9
  *
10
10
  * Run it:
@@ -33,6 +33,17 @@
33
33
  * AGENT_ENTRY_NAME public display name on the card
34
34
  * AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
35
35
  * AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
36
+ * AGENT_ENTRY_GUEST "1" = GUEST MOUNT: coexist with a site that keeps its own front
37
+ * page. The entry serves its card paths and the POST door named by
38
+ * AGENT_ENTRY_BASE_URL (which must then carry that path, e.g.
39
+ * https://example.com/agent) and answers NOTHING at `/` — no notice,
40
+ * no OPTIONS, no POST. Point your proxy at the door path and the
41
+ * well-known paths; the site keeps everything else, unchanged.
42
+ * AGENT_ENTRY_WBA_JWKS OPTIONAL: a JWKS document {"keys":[…]} as one JSON string —
43
+ * the Web Bot Auth key directory (verified out of band) whose
44
+ * holders this entry should RECOGNISE on inbound requests. Off
45
+ * when absent. Recognition only ADDS identity (env.wba_did);
46
+ * it never changes a verdict.
36
47
  */
37
48
 
38
49
  import { createAgentEntry, newSeedHex, didFromSeedHex, trimOuter, AGENT_CARD_PATH }
@@ -76,6 +87,11 @@ function responder(env) {
76
87
  // the account", and that is invisible if the ledger only lives in memory: an operator
77
88
  // watching this log is how you SEE a stranger's identity appear, and how you tell an
78
89
  // anonymous walk-in (no account) from a verified first contact (an account) at a glance.
90
+ // `[WBA]` is the transport-level identity (T107): the request's HTTP signature named a
91
+ // key we were configured to recognise — identification, never authorship of the text.
92
+ if (env.wba_did) {
93
+ console.log(`[WBA] transport signed by ${env.wba_did}`);
94
+ }
79
95
  // NOTE for anyone copying this file: `ledger` here is a **Map** (the Python reference in
80
96
  // examples/agent_entry_reference.py uses a dict) — use .get()/.size, not obj[key]/Object.keys.
81
97
  // The row is written BEFORE the backend is called, so `messages === 1` means "this very
@@ -107,6 +123,17 @@ function responder(env) {
107
123
  // tells a site operator nothing.
108
124
  let entry;
109
125
  try {
126
+ // A malformed AGENT_ENTRY_WBA_JWKS refuses to start, same posture as a bad domain
127
+ // list: silently starting without the keys the operator named only looks protective.
128
+ let wbaVerifiers = null;
129
+ if (process.env.AGENT_ENTRY_WBA_JWKS) {
130
+ try {
131
+ wbaVerifiers = JSON.parse(process.env.AGENT_ENTRY_WBA_JWKS);
132
+ } catch {
133
+ throw new TypeError('AGENT_ENTRY_WBA_JWKS is not valid JSON — paste the key '
134
+ + 'directory body ({"keys":[…]}) as one JSON string');
135
+ }
136
+ }
110
137
  entry = createAgentEntry({
111
138
  seedHex,
112
139
  name,
@@ -116,6 +143,8 @@ try {
116
143
  responder,
117
144
  openDoor: true, // "you may contact me, no introduction"
118
145
  anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
146
+ guest: process.env.AGENT_ENTRY_GUEST === '1',
147
+ wbaVerifiers,
119
148
  });
120
149
  } catch (err) {
121
150
  console.error(err && err.message ? err.message : String(err));
@@ -132,8 +161,29 @@ const server = entry.listen(port, host, () => {
132
161
  }
133
162
  console.log(` Listening on ${host}:${port} — POST a signed message/send to `
134
163
  + `${entry.mount || '/'}`);
164
+ if (process.env.AGENT_ENTRY_GUEST === '1') {
165
+ console.log(` Guest mount: the site keeps GET / — this entry answers the card at `
166
+ + `${AGENT_CARD_PATH} (and under ${entry.mount}) and POST ${entry.mount} only`);
167
+ }
135
168
  });
136
169
 
170
+ // The observation counters, surfaced the way the ledger is: on this runner's stdout,
171
+ // printed only when they changed. `[ua]` is greppable; the shape is entry.stats()
172
+ // verbatim ({family: {stage: n}}). `unref()` so the timer never holds the process open.
173
+ let lastStats = '';
174
+ setInterval(() => {
175
+ // Sorted keys at every level so the line is stable run to run (and diffable against
176
+ // the Python runner's `json.dumps(..., sort_keys=True)` spelling of the same shape).
177
+ const line = JSON.stringify(entry.stats(), (k, v) =>
178
+ (v && typeof v === 'object' && !Array.isArray(v))
179
+ ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]]))
180
+ : v);
181
+ if (line !== '{}' && line !== lastStats) {
182
+ console.log(`[ua] ${line}`);
183
+ lastStats = line;
184
+ }
185
+ }, 60_000).unref();
186
+
137
187
  // A port collision is the first thing anyone running this twice hits (a previous run that was
138
188
  // backgrounded and orphaned, usually). An unhandled 'error' event prints a Node stack trace,
139
189
  // which tells a site operator nothing — say what happened and what to do instead.