a peer-to-peer protocol, shipped in Rust
Many paths.
No masters.
Agora is a network where content has no home, names are cryptographic, and your followers are your infrastructure. Nodes are post offices. Apps are people. One mailbox, four verbs, no per-app servers — ever.
Every claim below maps to shipped, tested code. Mock-proven paths are labeled as such — we don't overclaim, because honesty is the product.
The problem: the address is the master
TCP/IP's original sin is that addresses are locations. Tie content to a server and you get everything wrong that follows: hosts that can be DDoS'd, banned, seized, or simply go bankrupt with your data on them. Relay-centric social protocols softened identity but kept the chokepoint — the relay still sees and gates every post, and the social graph is public by default.
Agora routes to names (hash-pinned, signed, auditable) between identities (keys — recoverable, not confiscatable) over chosen paths (explicit, priced), settled natively in nanos.
Nostr is the smuggler's door for old-internet usage — never the building. Wire compatibility with it is a documented property that lets existing content enter the swarm; the protocol itself stays pure and depends on nothing.
Five primitives
IDENTITY
Keys, recoverable not confiscatable. Swarm identities (ed25519) and app identities (secp256k1) — families never mixed.
shippedNAME
Hash-pinned, signed, auditable naming via signed DHT records. No human-name registry yet — by design, not by accident.
shipped at the record layerDATA
Hash-addressed content objects, served by whoever holds them. Onion fetch and blob transport included.
shippedACTOR
Named services — long-lived service identities with their own faces.
not startedPATH
Explicit, priced, property-tagged routes. Today: direct DHT exchange and anonymous 3-hop onion paths; per-route pricing is node-side metering.
partialThe one-minute picture
Nodes are post offices
They store sealed envelopes and pass them on. They never open them, and they never learn who dropped them off.
Apps are people
A feed, a photo vault, a VPN, a chat — all use the same mailbox. New app ideas never add node endpoints; the kind registry is the only extension surface.
Stored = sealed unless public
Public posts are postcards on a board. Private content is sealed in the app, before it touches a node. The node can read none of it.
the one mailbox —
POST /agora/put body IS the app-signed record
GET /agora/record/<id> local-first, one budgeted swarm fetch
GET /agora/outbox/<pk> an author's records, newest first
GET /agora/inbox?watcher= records addressed to you
Caching is the network
Every node serves what it holds; content has no home. 100 followers = 100 caches = unkillable posts.
No servers to attack
Nothing to DDoS, ban, or seize. Take down a hundred nodes and the content still answers from the others.
Privacy is a precondition
Routers see "an anonymous key asked for a hash" — nothing more. Sealed envelopes, one-time wrapper keys, onion drop-off: all shipped and adversarially swept.
Money is a layer
Relaying and caching earn mutual credit, netted to Monero. Your phone gets you paid to be part of the network. One nano = 10⁻⁹ XMR, integer math only.
Shipped, honestly
M1–M8 are real: relay core, onion-routed swarm, blobs, tips, the ledger, the feed, the mailbox API, sealed delivery, bilateral ledger sync — behind review gates and end-to-end tests with real sockets and round-trips, not "process running".
| Layer | Status |
|---|---|
| Road (node↔node transport) | done — onion routing, adversarially proven |
| Envelope (content) | done — seal-in-app; nodes store opaque records |
| Sender anonymity (who dropped it) | done — one-time wrapper keys + onion drop-off |
| Recipient anonymity | default inbox addressing is npub-derivable; pool tags are the mitigation tier |
| Timing correlation | expensive, not impossible — jitter + cover traffic raise the cost; a physics floor remains |
| Membership (NodeId + IP) | DHT gossip peers learn both; onion-only participation is on the maybe-list |
| Money: wallet-rpc paths (tips watcher, netting) | mock-proven only — never stagenet-verified |
What's next
- nextagora-feed — standalone app with its own keys, speaking the mailbox API; zero relay-protocol clients in the loop
- nextNative client onion-default — every app path anonymous by default, not opt-in
- nextNode-side ledger sync daemon — the bilateral convergence engine shipped in the runtime; the node job rides next
- nextCross-host blob replication — rendezvous + NAT traversal so phone-sized leaves can serve the swarm
- nextStagenet verification — the wallet-rpc paths graduate from mock-proven
- laterACTOR primitive — named services with their own faces
- lateraVPN — the Agora VPN, as an app like everything else
- lateragora-photos — client-side encrypted photo vault (spec done, waves pending)
- maybeMembership privacy — onion-only DHT participation; blocked on ~3× bandwidth cost
Roadmap items are plans, not promises — the binding record is each repo's
plan.md, written before the code.
Documentation
What an app codes against. This page is a dated snapshot of shipped behavior —
the canonical spec is agora-runtime/docs/protocol.md; the living record is each
repo's plan.md. Pin at the bottom.
Identity & key families
Two key families, never mixed:
| Family | Role |
|---|---|
| ed25519 swarm identity | Node identity. NodeId = blake3(pubkey)[..20]. Routes DHT traffic and signs record storage. |
| secp256k1 (BIP-340 x-only) | App/author identity. Signs records, seals envelopes. The body's own signature is authoritative everywhere — nodes never sign on an app's behalf. |
Records
The unit of all Agora content is the signed record:
{
"id": "<sha256 hex>",
"pubkey": "<64-hex BIP-340>",
"created_at": 1757900000,
"kind": 40001,
"tags": [["d", "my-album"]],
"content": "…",
"sig": "<128-hex schnorr>"
}
- id = sha256 over the canonical JSON array
[0,pubkey,created_at,kind,tags,content]— key order never relied on. - Signature: BIP-340 schnorr over the id by
pubkey. - Caps: DHT record value ≤ 32 KiB · record content ≤ 64 KiB · HTTP body ≤ 256 KiB · query limit default 500, max 5000.
- Classes: replaceable (0, 3, 10000–19999 — newer wins) · ephemeral
(20000–29999 — never stored) · addressable (30000–39999 — one per
dtag) · regular (dedup by id).
The record format is deliberately wire-compatible with Nostr's — a documented property that lets old-internet content enter through the node's marked door. The runtime itself imports zero relay-stack code; any change adding such types is rejected by definition.
Kind policy — the only extension surface
| Zone | Kinds | Policy |
|---|---|---|
| Operator | 9735, 10133, 30078 | node-only (receipts, payto, fee schedule) — mailbox put → 403 forbidden_kind |
| Agora app | 40000–49999 | validated like any record; DHT-published and outbox-indexed (discovery for free) |
| Sealed envelope | 13, 1059 | runtime sealing kinds; stored as opaque records |
| Compat | 0–39999, 50000–65535 | unchanged validation |
App ideas never add node endpoints — ever. Apps sign records with their own keys and compose the four verbs. The registry is the extension surface.
The mailbox — four verbs
| Verb | HTTP face | Behavior |
|---|---|---|
| put | POST /agora/put |
Body IS the signed record. Node validates → kind policy → store → swarm publish →
201 {"id","pubkey","dht"}. |
| get | GET /agora/record/<id> |
Local-first; on miss ONE budgeted swarm fetch (8 s) → validate → store → serve.
?fresh=1 forces the swarm lookup. |
| outbox | GET /agora/outbox/<pk>?limit=N |
The author's indexable records, newest first. A short page fires one budgeted swarm outbox pull (forgery-filtered). limit default 50, cap 200. |
| inbox | GET /agora/inbox?watcher=<pk> |
Local records whose tags declare this key as recipient, newest first, limit 200. Beyond loopback, requires a fresh signed auth event. |
Errors — match on the code, never the message
X-Agora-Api: 0
{"error":{"code":"<machine>","message":"<human>"}}
X-Agora-Error: <code>
| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | malformed body/shape/params |
bad_signature | 400 | id recompute or BIP-340 verification failed |
forbidden_kind | 403 | operator-only kind |
not_found | 404 | after local + budgeted swarm miss |
auth | 401 | non-loopback inbox without a valid fresh auth event |
conflict / duplicate | 409 | superseded replaceable record / idempotent re-put |
record_too_large | 413 | body over 256 KiB |
dropoff_failed | 502 | onion drop-off rejected or ack budget elapsed |
Guard: loopback-only by default (exact-address check);
--public-agora lifts it for mailbox faces only. CORS rides every
/agora/* response.
Three transports, one contract
| Path | Transport | Sealing |
|---|---|---|
| Agora ↔ Agora (feed) | DHT outbox — zero relays, cross-node proven | NIP-44-class seal to recipient — required (DHT stores are untrusted) |
| Generic DM via node mailbox | Node mailbox — opaque storage | Seal + one-time wrapper + onion drop-off — required (nodes can't read OR attribute) |
| Old-internet (plain Nostr user) | Node's NIP-01 door → relays | Full gift wrap — required (relays are untrusted) |
The anonymous drop-off submit face is POST /agora/put?via=onion (kind-1059
wrappers only): 202 acked, 502 dropoff_failed otherwise. The storing
node sees only the onion entry hop — never the sender.
Sealing — before it touches a node
put is a dumb pipe: it verifies signature + kind policy and never inspects
content. Apps seal private content themselves:
| Piece | Value |
|---|---|
| Conversation key | BIP-340 x-only ECDH shared-x → HKDF-SHA256-Extract (fixed salt) |
| Cipher | ChaCha20, counter 0 · HMAC-SHA256 tag over nonce ‖ ct, verified constant-time before any decryption |
| Padding | powers of two, min 32 — size analysis gets buckets, not lengths |
| Wire form | 0x02 ‖ nonce32 ‖ ct ‖ mac32, base64; length bounds checked before decoding |
| Expiration | optional ["expiration", unix] tag, ~14 days default; rejected with expired when past |
seal→ kind-13 record under the sender→recipient conversation key, signed by the sender's real key.gift_wrap→ kind-1059 wrapper under a fresh one-time ephemeral key, with 0–2 days of timing jitter.- The wrapper's
ptag is its own ephemeral key — never the recipient. A recipient tag would leak WHO receives every private message to every node storing it. - Delivery is pull-based via stealth tags (per-message, unlinkable) and inbox pointers signed by the wrapper's one-time key — no long-term key ever touches a stored pointer. Forgery-filtered on fetch.
- Honest residual: the first-contact tag is derivable from the public key alone — the npub leak. Pool tags are the recipient-published mitigation tier.
Every failure has a stable machine code (mac_fail, bad_seal,
invalid_rumor, expired, …). The payload format runs against the
official vector suite, vendored and executed in full.
Blobs
| Route | Behavior |
|---|---|
PUT /upload | BUD-11 auth: Authorization: Nostr <base64 kind-24242 event>. Over --max-blob-mb → 413 before the body is read. |
GET/HEAD /blob/<sha256> | Range requests, ETag → 304. |
DELETE /blob/<sha256> | Uploader-only. |
Replication rides DHT adverts + onion fetch. Serving leaves are opt-in
(serve_blobs + blob_dir), content-addressed only from their own dir —
they never forward and never charge fees. 32 MiB per-blob serve cap, ≤ 512 sorted adverts per
sweep. v0 honesty: adverts carry the local bind address (loopback default) — cross-host serving
needs an explicitly routable bind; NAT traversal is deferred.
Money — nano, bilateral, honest
- Unit: one nano = 10⁻⁹ XMR = 1000 piconero. All ledger math is integer nanos — never floats. One conversion site in the codebase, period.
- Ledger: one ledger per PAIR of nodes; every entry is 69 canonical bytes, signed by the authoring peer's swarm key, on a strict per-pair sequence chain. Balance = sum of deltas — order-free.
- Sync: attested balances + bilateral entry transport over onion-direct — a rejected batch never leaks reads; balances converge even when entries raced.
- Credit line: default ±1,000,000 nanos per pair — beyond it, metering degrades gracefully (skipped and counted).
- Fees (node-side metering): relay 1000 nanos/MiB (accrued at WS close) · blob-serve 2000 nanos/MiB. Netting settles pairs across the credit line from a spend-capable wallet, daily, above threshold.
- Mock-proven only: the wallet-rpc paths (tips watcher, netting) have never touched stagenet. The runbook exists; the claim doesn't pretend further.
Money is deliberately excluded from the Leaf profile — apps never carry operator money faces.
Swarm transport
- AGSW framing: one UDP datagram = one message — 4-byte magic, version, message type, payload. Big-endian; requests carry a 32-bit id echoed by the response (concurrent queries on one socket).
- DHT: minimal iterative Kademlia over UDP, 160-bit XOR keyspace, k = 8, α = 3, 2 s request timeout. Records are signed by the storing node; sequence is strictly monotonic; 65,536-record cap with republish-due-first eviction; 15-minute republish.
- Outbox indexing: DHT key
blake3-20("outbox:" ‖ pubkey); a pointer record maps NodeId → app key (key families differ). A listed record not authored by the outbox owner is dropped before the store. - Onion packets:
0x01 ‖ dest_token ‖ eph_pub ‖ AEAD-ctper layer; one ephemeral x25519 keypair per request; entry sees the mid, mid sees the exit, only the exit sees the object key. Answers return to the entry source — never an attacker-steerable target — under per-source rate limits.
The dial — Full / Borrow / Leaf
| Setting | For | What it is |
|---|---|---|
| Full | dedicated daemons | The only configuration that earns relay/storage fees. Never an app default. |
| Borrow | desktop default | The app calls the device's own node over localhost with its own keys, sealing everything itself. The node stores opaque envelopes and cannot attribute them. |
| Leaf | mobile default | The app embeds the runtime peer: identity + sealing + DHT client + client-only onion relay + small local store. No HTTP faces, no relay duty, no operator money. May serve blobs only when both knobs are set — content-addressed, never forwards. |
Versioning
Every /agora/* response carries X-Agora-Api: 0. Apps match on that
header and the machine error codes; nothing else is a contract.
Sources & governance
The work lives in sibling repositories: agora-runtime (the protocol, pure — zero relay-stack code, CI-greppable), agora-node (the daemon + the compatibility door), agora-cockpit (node-owner dashboard), agora-feed, agora-photos (apps).
Governance is transparency-log-first: specs are written before the code, decisions
live in each repo's plan.md, failures in MISTAKES.md. Where this page
and the log disagree, the log wins — and this page is stale.