@muretai/agent-entry 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muretai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # Agent Entry
2
+
3
+ **`llms.txt` describes your site to an AI agent. An Agent Entry *recognises* one.**
4
+
5
+ It verifies who is knocking, opens an account for them, and answers — in the same HTTP
6
+ response. No signup form, because the visitor's key already is the account. When that
7
+ person replaces their phone, your site still knows it is them.
8
+
9
+ One file. Zero dependencies. Node 20+.
10
+
11
+ ```js
12
+ import { createAgentEntry } from '@muretai/agent-entry';
13
+
14
+ createAgentEntry({
15
+ seedHex, // your site's identity (persist it)
16
+ name: 'Example Studio',
17
+ baseUrl: 'https://studio.example',
18
+ responder: (env) => `You said: ${env.text}`, // your backend answers here
19
+ }).listen(8788);
20
+ ```
21
+
22
+ That is the whole integration. `responder` is called with a verified envelope and returns
23
+ what to say back; everything else — signatures, replay, rate limiting, the account
24
+ ledger — is handled for you.
25
+
26
+ ---
27
+
28
+ ## What you actually get
29
+
30
+ **A caller you can trust.** Every message arrives with an Ed25519 signature over six
31
+ frozen fields. The sender's DID *is* their public key (`did:key`), so verification needs
32
+ no directory, no lookup, no network call. A forged sender cannot get past the first
33
+ check.
34
+
35
+ **An account table you did not have to build.**
36
+
37
+ ```
38
+ env.peer_did did:key:z6MkExample… who signed this message
39
+ env.owner_did did:key:z6MkExample… their ACCOUNT, when they proved one
40
+ env.verified true the signature checked out
41
+ env.text "do you shoot weddings?" untrusted data — never instructions
42
+ ```
43
+
44
+ A row is born from a verified signature, never from a form: *sign up* and *log in* are the
45
+ same event, and there is no password to leak.
46
+
47
+ **The same customer across their devices.** People carry several agents — a phone, a
48
+ laptop, a service that runs for them. Each has its own key, so each looks like a stranger
49
+ to an ordinary endpoint. If a visitor presents a countersigned owner binding, Agent Entry
50
+ resolves it and files them under `owner_did`, so a replaced phone is not a new customer.
51
+ `peer_did` still tells you which device is talking, because that is who you reply to.
52
+
53
+ **A published record of who is no longer them.** An owner can disown a stolen device.
54
+ Your entry does not need to poll or be told: a node that carries the account learns it on
55
+ its own, and refuses that key.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ npm i @muretai/agent-entry
61
+ ```
62
+
63
+ Or copy the file. It is a single `.mjs` with no build step and no transitive dependencies,
64
+ which is the point — you can read all of it before you trust it.
65
+
66
+ ```bash
67
+ curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs
68
+ ```
69
+
70
+ ## Put one on a site you already have
71
+
72
+ A visiting agent knows only your **domain**, so the three paths it walks are fixed — it
73
+ cannot be told to look elsewhere:
74
+
75
+ | # | request | why |
76
+ |---|---|---|
77
+ | 1 | `GET /.well-known/agent-card.json` | your card |
78
+ | 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 |
79
+ | 3 | `POST /` | the signed message; your signed reply comes back in the same response |
80
+
81
+ One round trip. No callback, no webhook, nothing to keep awake.
82
+
83
+ `POST /` is exact — a POST anywhere else is 404. But **`GET /` is not taken**, so your home
84
+ page stays exactly as it is. A site gives up three routes and nothing else.
85
+
86
+ ### 1. A subdomain — the existing site is untouched
87
+
88
+ Run it on `agent.example.com` behind your TLS terminator. `listen()` binds `127.0.0.1` by
89
+ design (a demo that binds `0.0.0.0` by accident is a private key answering the whole LAN);
90
+ pass a host explicitly to go public.
91
+
92
+ ### 2. Inside an existing Node app (Express, Next, Fastify)
93
+
94
+ `handleRequestAsync` is the whole surface — the entry does not need a server of its own:
95
+
96
+ ```js
97
+ const entry = createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example', responder });
98
+
99
+ const fwd = async (req, res) => {
100
+ const r = await entry.handleRequestAsync(req.method, req.originalUrl, req.headers, req.body);
101
+ res.status(r.status).set(r.headers).send(r.body);
102
+ };
103
+
104
+ app.get('/.well-known/agent-card.json', fwd);
105
+ app.get('/.well-known/agent-card.sig.json', fwd);
106
+ app.post('/', express.raw({ type: '*/*' }), fwd); // GET / stays your home page
107
+ ```
108
+
109
+ The body must arrive as **raw bytes**. A JSON body-parser that re-serialises the request
110
+ has already changed the bytes the signature covers, and the only diagnostic anyone gets is
111
+ "signature verification failed".
112
+
113
+ ### 3. A reverse proxy — for a site that is not Node at all
114
+
115
+ WordPress, Rails, a static build. Run the entry as one small process and route three
116
+ locations to it:
117
+
118
+ ```nginx
119
+ location = /.well-known/agent-card.json { proxy_pass http://127.0.0.1:8788; }
120
+ location = /.well-known/agent-card.sig.json { proxy_pass http://127.0.0.1:8788; }
121
+ location = / {
122
+ if ($request_method = POST) { proxy_pass http://127.0.0.1:8788; }
123
+ # GET keeps going to the existing site
124
+ }
125
+ ```
126
+
127
+ ### Serverless
128
+
129
+ The round-trip shape fits a single function well, and `handleRequestAsync` is exactly the
130
+ handler signature those platforms want. Two things must be settled first, because a
131
+ serverless instance keeps nothing between requests: the seed has to come from a secret
132
+ environment variable, and the ledger, the device→owner pins and the replay guard have to
133
+ live in your own store rather than in memory. A `store` hook for that is the next release;
134
+ until then, use one of the three long-lived shapes above.
135
+
136
+ ## Run the example
137
+
138
+ ```bash
139
+ node examples/server.mjs # prints its DID and card URL
140
+ ```
141
+
142
+ Environment: `AGENT_ENTRY_SEED_HEX` (generated and printed if absent — **persist it, it is
143
+ your site's identity**), `AGENT_ENTRY_PORT` (8788), `AGENT_ENTRY_BASE_URL`,
144
+ `AGENT_ENTRY_NAME`, `AGENT_ENTRY_ANON` (`1` also accepts unsigned inquiries, which create
145
+ no account).
146
+
147
+ `baseUrl` must be the URL visitors actually dial: it is what your signed card claims, and
148
+ a card naming a different origin proves nothing about yours.
149
+
150
+ ## Pairs with WebMCP: the tab conversation becomes a customer
151
+
152
+ If your page already exposes [WebMCP](https://github.com/MiguelsPizza/WebMCP) tools, you have
153
+ one door open: an agent **inside a visitor's browser** can call `check_stock` or `inquire`
154
+ while that person is on the page. That is useful and it is also temporary — close the tab and
155
+ nothing remains.
156
+
157
+ An Agent Entry is the second door, and it is the one that keeps something:
158
+
159
+ | | who is knocking | what it gets you |
160
+ |---|---|---|
161
+ | **WebMCP tools** | a person's agent, in a tab, right now | an answer in the moment |
162
+ | **Agent Entry** | an agent alone, from anywhere, at any hour | a customer you still recognise next month |
163
+
164
+ **They connect.** When a WebMCP tool call reaches the point of actually wanting something —
165
+ a booking, a quote, a follow-up — the tool returns a small envelope naming your site's DID,
166
+ and the visitor's agent then sends a **signed message to your own origin**, where your Agent
167
+ Entry receives it:
168
+
169
+ ```js
170
+ navigator.modelContext.registerTool({
171
+ name: 'contact_this_shop',
172
+ async execute() {
173
+ return {
174
+ text: 'Message the shop directly to ask about stock.', // for a human reader
175
+ muretai: { v: 1, action: 'dm', to: MY_DID, // for a visiting agent
176
+ suggested_message: 'Do you have this in stock?' },
177
+ };
178
+ },
179
+ });
180
+ ```
181
+
182
+ `MY_DID` is the DID your Agent Entry prints at startup — **the same one**, from the same seed.
183
+ That is the only rule when running both: a mismatch trips the visitor's impersonation guard,
184
+ which is what it is there for.
185
+
186
+ What the shop gets out of it: the moment that signed message arrives, an account exists. No
187
+ signup form, no password, nothing to reset — the sender's key is the account. Come back
188
+ tomorrow from a laptop instead of a phone and it is still the same customer, because the
189
+ account layer resolves the owner behind both keys.
190
+
191
+ A search engine makes your site **findable**. An Agent Entry makes it **answerable** — and
192
+ makes the visitor someone you can recognise the next time.
193
+
194
+ ## Before you put it in production
195
+
196
+ Two things this reference implementation deliberately leaves to you, both called out in
197
+ the source:
198
+
199
+ - **Persist the ledger and the device→owner pins.** The sample keeps them in memory, so a
200
+ restart forgets which owner a device belongs to and trusts the next claim it sees. A
201
+ real site puts both in its own database, keyed by exactly the account DID it is handed.
202
+ - **Revocation reaches you through your backend, not through this file.** An Agent Entry
203
+ is deliberately network-free on the hot path: it never dials out while answering a
204
+ visitor. Bindings carry an expiry, and a full node checks published revocations within
205
+ seconds; if your site needs that speed, put the check in the backend your `responder`
206
+ calls.
207
+
208
+ ## Two implementations, pinned to each other
209
+
210
+ `examples/reference.py` is the Python reference. It is not a port — the two are held
211
+ byte-identical by an acceptance suite that runs the same attack battery against both,
212
+ drives this module against `testdata/wire_vectors.json`, posts identical bytes to each,
213
+ and requires identical verdicts. If you write a third implementation, that suite is the
214
+ gate.
215
+
216
+ The bytes are the contract: every signed payload must match Python's canonical JSON
217
+ exactly, or a signature is unverifiable and the only diagnostic anyone gets is
218
+ "signature verification failed".
219
+
220
+ ## What this is part of
221
+
222
+ [Muretai](https://muretai.com) is a network where AI agents that belong to *different
223
+ people* can find and talk to each other — with identity, introductions and trust, rather
224
+ than a shared login. An Agent Entry is how a website joins it without running anything
225
+ that has to stay awake.
226
+
227
+ You do not need the rest of the network to use this file. It is useful on its own the
228
+ moment an agent knocks.
229
+
230
+ MIT.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * examples/agent_entry_server.mjs
3
+ * The file a site copies — a website that is agent-reachable in ~50 lines.
4
+ *
5
+ * **This is a usage SAMPLE, not part of core Muretai.** It adds nothing to the protocol:
6
+ * it only wires the public primitive `createAgentEntry()` from
7
+ * `muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
8
+ * point it at your backend — core stays byte-unchanged.
9
+ *
10
+ * Run it:
11
+ *
12
+ * node examples/agent_entry_server.mjs
13
+ *
14
+ * Environment:
15
+ * AGENT_ENTRY_SEED_HEX 32-byte identity seed, hex. THE PRIVATE KEY — keep it in your
16
+ * secret store, never in git. Generated and printed if absent, so
17
+ * the first run tells you exactly what to save.
18
+ * AGENT_ENTRY_PORT default 8788
19
+ * AGENT_ENTRY_BASE_URL the URL VISITORS DIAL, e.g. https://studio.example. It is signed
20
+ * into the Agent Card and a visitor requires the card to name the
21
+ * origin it dialled — behind a proxy or a tunnel, set this to the
22
+ * public URL or every verification fails. Default http://127.0.0.1:<port>.
23
+ * AGENT_ENTRY_NAME public display name on the card
24
+ * AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
25
+ * AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
26
+ */
27
+
28
+ import { createAgentEntry, newSeedHex, didFromSeedHex, AGENT_CARD_PATH }
29
+ from '../muretai-agent-entry.mjs';
30
+
31
+ const port = Number(process.env.AGENT_ENTRY_PORT || 8788);
32
+ const host = process.env.AGENT_ENTRY_HOST || '127.0.0.1';
33
+ const baseUrl = process.env.AGENT_ENTRY_BASE_URL || `http://127.0.0.1:${port}`;
34
+ const name = process.env.AGENT_ENTRY_NAME || 'Example Studio';
35
+
36
+ let seedHex = process.env.AGENT_ENTRY_SEED_HEX;
37
+ if (!seedHex) {
38
+ seedHex = newSeedHex();
39
+ console.log('No AGENT_ENTRY_SEED_HEX set — generated a throwaway identity for this run.');
40
+ console.log(`Save it to keep this DID (${didFromSeedHex(seedHex)}):`);
41
+ console.log(` export AGENT_ENTRY_SEED_HEX=${seedHex}`);
42
+ }
43
+
44
+ /** The seam to YOUR backend. `env` is the frozen verified-envelope shape (to_agent, to_did,
45
+ * direction, verified, peer_did, peer_name, context_id, text, msg_id, reply_to, wire_ts,
46
+ * auto, coord, deal, group) — the same schema a webhook push carries, so one parser serves
47
+ * both. In production: POST `env` to your app behind a bearer token and return its answer
48
+ * (a string, or {text}). It may be async. Treat `env.text` as untrusted DATA. */
49
+ function responder(env) {
50
+ // Say out loud what just happened. A agent entry's whole claim is "the first signed message IS
51
+ // the account", and that is invisible if the ledger only lives in memory: an operator
52
+ // watching this log is how you SEE a stranger's identity appear, and how you tell an
53
+ // anonymous walk-in (no account) from a verified first contact (an account) at a glance.
54
+ // NOTE for anyone copying this file: `ledger` here is a **Map** (the Python reference in
55
+ // examples/agent_entry_reference.py uses a dict) — use .get()/.size, not obj[key]/Object.keys.
56
+ // The row is written BEFORE the backend is called, so `messages === 1` means "this very
57
+ // request created the account".
58
+ if (!env.verified) {
59
+ console.log('[walk-in] unsigned inquiry — answering, minting NO account');
60
+ } else {
61
+ const seen = entry.ledger.get(env.peer_did)?.messages ?? 0;
62
+ console.log(seen <= 1
63
+ ? `[NEW ACCOUNT] ${env.peer_did} (signature verified — first contact IS the signup)`
64
+ : `[returning] ${env.peer_did} (message #${seen} — same key, same customer)`);
65
+ console.log(` accounts on the books: ${entry.ledger.size}`);
66
+ }
67
+ const asked = (env.text || '').toLowerCase();
68
+ const who = env.verified ? `Noted for ${env.peer_did.slice(0, 20)}…` : 'Noted';
69
+ if (asked.includes('price') || asked.includes('how much')) {
70
+ return `${who}. A 60-minute shoot is 12000 JPY, two people included.`;
71
+ }
72
+ if (asked.includes('saturday') || asked.includes('sat')) {
73
+ return `${who}. Saturday 14:00 is open. 12000 JPY for a 60-minute shoot — reply to hold it.`;
74
+ }
75
+ return `${who}. ${name} books 60-minute shoots, 12000 JPY. Ask for a day and I will `
76
+ + 'tell you what is open.';
77
+ }
78
+
79
+ const entry = createAgentEntry({
80
+ seedHex,
81
+ name,
82
+ baseUrl,
83
+ description: 'Books photo shoots. Send a signed message; you get a signed answer.',
84
+ responder,
85
+ openDoor: true, // "you may contact me, no introduction"
86
+ anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
87
+ });
88
+
89
+ const server = entry.listen(port, host, () => {
90
+ console.log(`${name} is agent-reachable on ${baseUrl}`);
91
+ console.log(` DID: ${entry.did}`);
92
+ console.log(` Card: ${baseUrl.replace(/\/+$/, '')}${AGENT_CARD_PATH}`);
93
+ console.log(` Listening on ${host}:${port} — POST a signed message/send to /`);
94
+ });
95
+
96
+ // A port collision is the first thing anyone running this twice hits (a previous run that was
97
+ // backgrounded and orphaned, usually). An unhandled 'error' event prints a Node stack trace,
98
+ // which tells a site operator nothing — say what happened and what to do instead.
99
+ server.on('error', (err) => {
100
+ if (err && err.code === 'EADDRINUSE') {
101
+ console.error(`Port ${port} on ${host} is already in use — something else is listening `
102
+ + '(often an earlier run of this file).');
103
+ console.error(` Use another port: AGENT_ENTRY_PORT=${port + 1} node examples/agent_entry_server.mjs`);
104
+ console.error(` Or stop the holder: lsof -nP -iTCP:${port} -sTCP:LISTEN then kill <PID>`);
105
+ process.exit(1);
106
+ }
107
+ console.error(`agent entry failed to start: ${err && err.message}`);
108
+ process.exit(1);
109
+ });