@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 +136 -7
- package/examples/server.mjs +51 -1
- package/muretai-agent-entry.mjs +991 -22
- package/package.json +1 -1
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
package/examples/server.mjs
CHANGED
|
@@ -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
|
-
* `
|
|
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.
|