The send side of end-to-end encrypted agent messaging: give it the messaging service, the
recipient, and the text; it fetches the recipient's key from that service, encrypts, and delivers
the ciphertext there, acting for the person over an AAuth call chain. This is the code that runs
the hosted default at encrypt.aauth.dev, and it is what you deploy to run your own. The read side
is decrypt (decrypt.aauth.dev).
Status: built 2026-09-14 (plan D26, Part C). One operation, sendMessage; text only, 64 KB
(D25). Nothing in the code names a messaging service: resource is a parameter whose default is the
DEFAULT_RESOURCE var in wrangler.jsonc (https://secret.agent.coop for this host), and the tests
chain to a fake at a made-up host.
- A Cloudflare Worker, an AAuth resource with
access_mode: person-token, and an AAuth agent toward the messaging service (/.well-known/aauth-agent.json,aauth:send@<host>). Stateless: no D1, no R2, no KV. In secret.agent.coop's terms it is a send service (plan D27): a person's account names theirs assend_message_with. POST /send(sendMessage){from, to, text, resource?, idempotency_key?}:- verifies the caller's person token (audience this service);
- asks the Person Server that issued it for a person token for
resource, with the caller's token asupstream_token(call chaining; no consent card); getPublicKey:GET {resource}/public-key?from=&to=with that token, signed withAGENT_KEY;- encrypts
{"text": …}to that key as one compact JWE, ECDH-ES + A256GCM on P-256, per spec/container.md, on Web Crypto directly (src/jwe.ts); uploadMessage:POST {resource}/messages {from, to, jwe}; on409 key_rotatedit goes back to step 3, once;- returns
{id, from, to, kid, size, resource}.
fromis required: a person has several addresses.resourceis optional.- Refusals from the messaging service (
not_connected,recipient_has_no_key,rate_limited) pass through with their status andstep: get_public_key | upload_message.text_too_long(413) andattachments_not_supported(400) are this service's.502withstep: person_tokenwhen the Person Server refuses the chain. - It sees the plaintext of each message and the recipient's address, holds a token in the person's name for the messaging service for the length of the call, and stores nothing. Events carry a hash of the identity, the size, the key id, the messaging service, and the message id: never text or addresses. Privacy.
openapi.jsonand/.well-known/aauth-resource.jsonare served withCache-Control: public, max-age=300and anETag.
Two keys, both Ed25519 private JWKs: SIGNING_KEY signs the agent token (published at
/.well-known/jwks.json); AGENT_KEY signs the requests this service makes and is cnf in the
agent token and in the chained person token. src/agent-identity.ts and test/fake-ps/ are copies
of secret-agent-coop's; a shared @aauth/intermediary package is a later increment.
With the AAuth MCP: connect_resources {items: [{resource: "encrypt.aauth.dev"}]} (one card), then
invoke {resource: "encrypt.aauth.dev", op_id: "sendMessage", body: {from: "mailto:you@example.com", to: "mailto:bob@example.com", text: "…"}}.
Put the fields directly in body. The recipient reads with listMessages at the messaging service
and readMessage {id} at their read service (default decrypt.aauth.dev). The full flow is in the
secret-agent-coop skill.
npm install
npm run generate-key | npx wrangler secret put SIGNING_KEY
npm run generate-key | npx wrangler secret put AGENT_KEY
npx wrangler deploy # set your own route / custom domain in wrangler.jsonc
Then connect_resources your host from your agent and call sendMessage there. The messaging
service accepts a message from any encrypt and does not need to know which one delivered it.
npm test # vitest, Workers pool; fake Person Server and fake messaging service in test/fake-ps
npm run typecheck
bash scripts/smoke.sh [base_url] # live: public surface, agent document, 401 shape
MIT