Agora

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.

The one-minute picture

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.

shipped

NAME

Hash-pinned, signed, auditable naming via signed DHT records. No human-name registry yet — by design, not by accident.

shipped at the record layer

DATA

Hash-addressed content objects, served by whoever holds them. Onion fetch and blob transport included.

shipped

ACTOR

Named services — long-lived service identities with their own faces.

not started

PATH

Explicit, priced, property-tagged routes. Today: direct DHT exchange and anonymous 3-hop onion paths; per-route pricing is node-side metering.

partial

The 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".

LayerStatus
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 anonymitydefault inbox addressing is npub-derivable; pool tags are the mitigation tier
Timing correlationexpensive, 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:

FamilyRole
ed25519 swarm identityNode 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 d tag) · 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

ZoneKindsPolicy
Operator9735, 10133, 30078node-only (receipts, payto, fee schedule) — mailbox put → 403 forbidden_kind
Agora app40000–49999validated like any record; DHT-published and outbox-indexed (discovery for free)
Sealed envelope13, 1059runtime sealing kinds; stored as opaque records
Compat0–39999, 50000–65535unchanged 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

VerbHTTP faceBehavior
putPOST /agora/put Body IS the signed record. Node validates → kind policy → store → swarm publish → 201 {"id","pubkey","dht"}.
getGET /agora/record/<id> Local-first; on miss ONE budgeted swarm fetch (8 s) → validate → store → serve. ?fresh=1 forces the swarm lookup.
outboxGET /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.
inboxGET /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>
CodeHTTPMeaning
bad_request400malformed body/shape/params
bad_signature400id recompute or BIP-340 verification failed
forbidden_kind403operator-only kind
not_found404after local + budgeted swarm miss
auth401non-loopback inbox without a valid fresh auth event
conflict / duplicate409superseded replaceable record / idempotent re-put
record_too_large413body over 256 KiB
dropoff_failed502onion 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

PathTransportSealing
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 mailboxNode 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:

PieceValue
Conversation keyBIP-340 x-only ECDH shared-x → HKDF-SHA256-Extract (fixed salt)
CipherChaCha20, counter 0 · HMAC-SHA256 tag over nonce ‖ ct, verified constant-time before any decryption
Paddingpowers of two, min 32 — size analysis gets buckets, not lengths
Wire form0x02 ‖ nonce32 ‖ ct ‖ mac32, base64; length bounds checked before decoding
Expirationoptional ["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 p tag 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

RouteBehavior
PUT /uploadBUD-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-ct per 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

SettingForWhat it is
Fulldedicated daemons The only configuration that earns relay/storage fees. Never an app default.
Borrowdesktop 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.
Leafmobile 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.