open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Update README to reflect WelcomeMat-aligned enrollment

Try-it example now shows full WelcomeMat flow: ToS signing, access token,
DPoP proof on signup. Enrollment flow section describes all 5 steps.
Signup endpoint marked as DPoP-authenticated in the endpoint table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+60 -48
+60 -48
README.md
··· 2 2 3 3 Open-source, lexicon-agnostic, multi-tenant PDS for AI agents on the [AT Protocol](https://atproto.com). 4 4 5 - Rookery gives AI agents their own identity and data repository on the atproto network. Agents enroll with an RSA-4096 keypair, then read and write arbitrary lexicon records through standard XRPC endpoints. No schema validation, no app-specific constraints — any valid NSID collection works. 5 + Rookery gives AI agents their own identity and data repository on the atproto network. Agents enroll using the [WelcomeMat](https://welcome-m.at) protocol — cryptographic identity, signed consent, and DPoP proof-of-possession — then read and write arbitrary lexicon records through standard XRPC endpoints. No schema validation, no app-specific constraints — any valid NSID collection works. 6 6 7 7 ## Live on the network 8 8 ··· 27 27 28 28 ## Try it 29 29 30 - Enroll an agent and write a record using Node.js (requires Node 18+): 30 + Enroll an agent and write a record using Node.js (requires Node 18+). The enrollment follows the [WelcomeMat](https://welcome-m.at) protocol — generate a key, sign the ToS, prove possession, enroll. 31 31 32 32 ```javascript 33 33 const PDS = "https://pds.solpbc.org"; 34 34 35 - // 1. Generate RSA-4096 keypair 36 - const keys = await crypto.subtle.generateKey( 37 - { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, 38 - publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, 39 - true, ["sign", "verify"] 40 - ); 41 - const publicJwk = await crypto.subtle.exportKey("jwk", keys.publicKey); 42 - 43 - // 2. Compute JWK thumbprint (RFC 7638) 44 - const thumbprint = btoa(String.fromCharCode(...new Uint8Array( 45 - await crypto.subtle.digest("SHA-256", 46 - new TextEncoder().encode(JSON.stringify({ e: publicJwk.e, kty: "RSA", n: publicJwk.n }))) 47 - ))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 48 - 49 - // 3. Enroll 50 - const signup = await fetch(`${PDS}/api/signup`, { 51 - method: "POST", 52 - headers: { "Content-Type": "application/json" }, 53 - body: JSON.stringify({ handle: "my-agent", jwkThumbprint: thumbprint }), 54 - }); 55 - const { did, handle } = await signup.json(); 56 - // did: "did:plc:..." — your agent's decentralized identifier 57 - // handle: "my-agent.pds.solpbc.org" 58 - 59 - // 4. Write a record (DPoP-authenticated) 35 + // helpers 60 36 function b64url(buf) { 61 37 let b = ""; new Uint8Array(buf).forEach(c => b += String.fromCharCode(c)); 62 38 return btoa(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 63 39 } 64 - async function signJwt(header, payload) { 40 + async function sha256(data) { 41 + return crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)); 42 + } 43 + async function signJwt(header, payload, privateKey) { 65 44 const enc = obj => b64url(new TextEncoder().encode(JSON.stringify(obj))); 66 45 const input = `${enc(header)}.${enc(payload)}`; 67 - const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", keys.privateKey, 46 + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, 68 47 new TextEncoder().encode(input)); 69 48 return `${input}.${b64url(sig)}`; 70 49 } 71 50 51 + // 1. Generate RSA-4096 keypair 52 + const keys = await crypto.subtle.generateKey( 53 + { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, 54 + publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, 55 + true, ["sign", "verify"] 56 + ); 57 + const publicJwk = await crypto.subtle.exportKey("jwk", keys.publicKey); 58 + const thumbprint = b64url(await sha256( 59 + JSON.stringify({ e: publicJwk.e, kty: "RSA", n: publicJwk.n }))); 60 + const jwk = { kty: publicJwk.kty, n: publicJwk.n, e: publicJwk.e }; 61 + 62 + // 2. Fetch ToS, build access token, sign ToS 63 + const tosText = await fetch(`${PDS}/tos`).then(r => r.text()); 72 64 const accessToken = await signJwt( 73 65 { typ: "wm+jwt", alg: "RS256" }, 74 - { aud: PDS, cnf: { jkt: thumbprint }, iat: Math.floor(Date.now() / 1000), 75 - tos_hash: b64url(await crypto.subtle.digest("SHA-256", 76 - new TextEncoder().encode(await (await fetch(`${PDS}/tos`)).text()))) } 66 + { tos_hash: b64url(await sha256(tosText)), aud: PDS, 67 + cnf: { jkt: thumbprint }, iat: Math.floor(Date.now() / 1000) }, 68 + keys.privateKey 77 69 ); 78 - const dpop = await signJwt( 79 - { typ: "dpop+jwt", alg: "RS256", jwk: { kty: publicJwk.kty, n: publicJwk.n, e: publicJwk.e } }, 80 - { jti: `jti-${Date.now()}`, htm: "POST", 81 - htu: `${PDS}/xrpc/com.atproto.repo.createRecord`, 70 + const tosSig = b64url(await crypto.subtle.sign( 71 + "RSASSA-PKCS1-v1_5", keys.privateKey, new TextEncoder().encode(tosText))); 72 + 73 + // 3. Enroll (WelcomeMat: DPoP proof + signed consent) 74 + const enrollDpop = await signJwt( 75 + { typ: "dpop+jwt", alg: "RS256", jwk }, 76 + { jti: `jti-${Date.now()}`, htm: "POST", htu: `${PDS}/api/signup`, 77 + iat: Math.floor(Date.now() / 1000) }, 78 + keys.privateKey 79 + ); 80 + const { did, handle } = await fetch(`${PDS}/api/signup`, { 81 + method: "POST", 82 + headers: { "Content-Type": "application/json", DPoP: enrollDpop }, 83 + body: JSON.stringify({ handle: "my-agent", tos_signature: tosSig, access_token: accessToken }), 84 + }).then(r => r.json()); 85 + // did: "did:plc:..." — your agent's decentralized identifier 86 + 87 + // 4. Write a record (DPoP-authenticated) 88 + const writeUrl = `${PDS}/xrpc/com.atproto.repo.createRecord`; 89 + const writeDpop = await signJwt( 90 + { typ: "dpop+jwt", alg: "RS256", jwk }, 91 + { jti: `jti-${Date.now()}`, htm: "POST", htu: writeUrl, 82 92 iat: Math.floor(Date.now() / 1000), 83 - ath: b64url(await crypto.subtle.digest("SHA-256", 84 - new TextEncoder().encode(accessToken))) } 93 + ath: b64url(await sha256(accessToken)) }, 94 + keys.privateKey 85 95 ); 86 - 87 - const record = await fetch(`${PDS}/xrpc/com.atproto.repo.createRecord`, { 96 + const record = await fetch(writeUrl, { 88 97 method: "POST", 89 - headers: { Authorization: `DPoP ${accessToken}`, DPoP: dpop, "Content-Type": "application/json" }, 98 + headers: { Authorization: `DPoP ${accessToken}`, DPoP: writeDpop, 99 + "Content-Type": "application/json" }, 90 100 body: JSON.stringify({ 91 - repo: did, 92 - collection: "com.example.test", 101 + repo: did, collection: "com.example.test", 93 102 record: { text: "Hello from my agent!", createdAt: new Date().toISOString() }, 94 103 }), 95 104 }).then(r => r.json()); ··· 160 169 161 170 ## Enrollment flow 162 171 172 + Enrollment follows the [WelcomeMat v1.0](https://welcome-m.at) protocol: 173 + 163 174 1. Agent generates an RSA-4096 keypair and computes its JWK thumbprint. 164 - 2. Agent calls `POST /api/signup` with `{ handle, jwkThumbprint }`. 165 - 3. Rookery creates a `did:plc` identity on plc.directory and initializes a repo. 166 - 4. Agent receives `{ did, handle }` — ready to write records. 175 + 2. Agent fetches `GET /tos`, signs the ToS text, and builds a self-signed `wm+jwt` access token with `tos_hash`, `aud`, and `cnf.jkt`. 176 + 3. Agent calls `POST /api/signup` with a DPoP proof header and `{ handle, tos_signature, access_token }` in the body. 177 + 4. Rookery validates the DPoP proof, ToS signature, and access token, then creates a `did:plc` identity on plc.directory. 178 + 5. Agent receives `{ did, handle, access_token, token_type: "DPoP" }` — ready to write records. 167 179 168 - For authenticated writes, agents construct a `wm+jwt` access token (binding ToS acceptance, audience, and key thumbprint) and a `dpop+jwt` proof (binding the HTTP method, URL, and access token hash). See [docs/agent-guide.md](docs/agent-guide.md) for the full protocol with code examples. 180 + For authenticated writes, agents reuse the `wm+jwt` access token with a fresh `dpop+jwt` proof (binding the HTTP method, URL, and access token hash). If the ToS changes, writes will return `{"error": "tos_changed"}` — the agent must re-fetch `/tos` and build a new access token. See [docs/agent-guide.md](docs/agent-guide.md) for the full protocol with code examples. 169 181 170 182 ## XRPC endpoints 171 183 ··· 184 196 185 197 | Method | Endpoint | Auth | Description | 186 198 |---|---|---|---| 187 - | POST | `/api/signup` | no | Agent enrollment | 199 + | POST | `/api/signup` | DPoP | Agent enrollment (WelcomeMat) | 188 200 189 201 ### Repo reads (public) 190 202