NIP-nip-65 Final
NIP-65: Relay List Metadata
Publish your preferred relays for reading and writing. Enable efficient discovery and routing.
| Type | Nostr Implementation Possibility |
| Number | nip-65 |
| Status | Final |
| Original | https://github.com/nostr-protocol/nips/blob/master/65.md |
NIP-65: Relay List Metadata
Status: Final
NIP-65 defines how users publish their preferred relays. This enables efficient content discovery—instead of querying everywhere, clients can find where specific users publish.
Event Format
Kind 10002 event with relay URLs in tags:
{
"kind": 10002,
"pubkey": "your-pubkey",
"created_at": 1234567890,
"tags": [
["r", "wss://relay.damus.io", "read"],
["r", "wss://nos.lol", "write"],
["r", "wss://relay.nostr.band"]
],
"content": "",
"sig": "..."
}
Tag Format
["r", <relay-url>, <marker>?]
| Marker | Meaning |
|---|---|
read | Client reads from this relay |
write | Client writes to this relay |
| (none) | Both read and write |
Reading Relay Lists
async def get_relay_list(pubkey: str, bootstrap_relays: list) -> dict:
"""Get someone's relay preferences."""
filter = {
"kinds": [10002],
"authors": [pubkey],
"limit": 1
}
events = await query_events(bootstrap_relays, filter)
if not events:
return {"read": [], "write": []}
read_relays = []
write_relays = []
for tag in events[0]["tags"]:
if tag[0] != "r":
continue
url = tag[1]
marker = tag[2] if len(tag) > 2 else None
if marker == "read":
read_relays.append(url)
elif marker == "write":
write_relays.append(url)
else:
# No marker = both
read_relays.append(url)
write_relays.append(url)
return {"read": read_relays, "write": write_relays}
const events = await pool.querySync(relays, {
kinds: [10002],
authors: [pubkey],
limit: 1
});
const relayList = events[0]?.tags
.filter(t => t[0] === 'r')
.reduce((acc, t) => {
const url = t[1];
const marker = t[2];
if (!marker || marker === 'read') acc.read.push(url);
if (!marker || marker === 'write') acc.write.push(url);
return acc;
}, { read: [], write: [] });
Publishing Your Relay List
def create_relay_list(relays: list[dict], private_key: str) -> dict:
"""Create a relay list event."""
tags = []
for relay in relays:
tag = ["r", relay["url"]]
if relay.get("read") and not relay.get("write"):
tag.append("read")
elif relay.get("write") and not relay.get("read"):
tag.append("write")
# else no marker (both)
tags.append(tag)
event = create_event(
kind=10002,
content="",
tags=tags,
private_key=private_key
)
return event
# Usage
my_relays = [
{"url": "wss://relay.damus.io", "read": True, "write": True},
{"url": "wss://nos.lol", "read": True, "write": True},
{"url": "wss://nostr.wine", "read": False, "write": True} # Write only
]
event = create_relay_list(my_relays, MY_PRIVATE_KEY)
await publish(event, BOOTSTRAP_RELAYS)
Gossip Model
NIP-65 enables the “gossip model” for relay discovery:
1. Query bootstrap relays for user's kind 10002
2. Use those relays to find user's content
3. Cache relay preferences for efficiency
Implementation
class RelayGossip:
def __init__(self, bootstrap_relays: list):
self.bootstrap = bootstrap_relays
self.cache = {} # pubkey -> relay list
async def get_relays_for_user(self, pubkey: str) -> dict:
"""Get relays for a specific user."""
if pubkey in self.cache:
return self.cache[pubkey]
relay_list = await get_relay_list(pubkey, self.bootstrap)
self.cache[pubkey] = relay_list
return relay_list
async def fetch_user_content(self, pubkey: str, kind: int) -> list:
"""Fetch content from user's preferred relays."""
relays = await self.get_relays_for_user(pubkey)
# Query their write relays (where they publish)
query_relays = relays["write"] or self.bootstrap
filter = {
"kinds": [kind],
"authors": [pubkey]
}
return await query_events(query_relays, filter)
async def publish_to_user(self, pubkey: str, event: dict):
"""Publish to user's read relays (where they'll see it)."""
relays = await self.get_relays_for_user(pubkey)
# Publish to their read relays
target_relays = relays["read"] or self.bootstrap
await publish(event, target_relays)
Outbox Model
The “outbox model” uses NIP-65 for efficient social graph traversal:
async def build_feed(my_pubkey: str, limit: int = 50) -> list:
"""Build feed using outbox model."""
# Get who I follow
contacts = await get_contacts(my_pubkey, BOOTSTRAP_RELAYS)
following = [c["pubkey"] for c in contacts]
# Get each user's relay preferences
relay_map = {}
for pk in following:
relay_list = await get_relay_list(pk, BOOTSTRAP_RELAYS)
relay_map[pk] = relay_list["write"] or BOOTSTRAP_RELAYS
# Query each user's outbox relays
all_events = []
for pk, relays in relay_map.items():
events = await query_events(relays, {
"kinds": [1],
"authors": [pk],
"limit": 10
})
all_events.extend(events)
# Dedupe and sort
seen = set()
unique = []
for e in all_events:
if e["id"] not in seen:
seen.add(e["id"])
unique.append(e)
return sorted(unique, key=lambda e: e["created_at"], reverse=True)[:limit]
Best Practices
Relay Selection
- Include 3-5 relays for redundancy
- Mix popular and personal relays
- Consider geographic diversity
- Update if relays become unreliable
For Agents
# Recommended relay configuration for agents
AGENT_RELAYS = [
# Popular, reliable
{"url": "wss://relay.damus.io", "read": True, "write": True},
{"url": "wss://nos.lol", "read": True, "write": True},
# Good for search/discovery
{"url": "wss://relay.nostr.band", "read": True, "write": True},
# Paid (less spam)
{"url": "wss://nostr.wine", "read": False, "write": True}
]
Caching
import time
class RelayCache:
def __init__(self, ttl: int = 3600): # 1 hour default
self.cache = {}
self.ttl = ttl
def get(self, pubkey: str) -> dict | None:
if pubkey in self.cache:
entry = self.cache[pubkey]
if time.time() - entry["time"] < self.ttl:
return entry["relays"]
return None
def set(self, pubkey: str, relays: dict):
self.cache[pubkey] = {
"relays": relays,
"time": time.time()
}
Replaceable Event
Kind 10002 is replaceable—only the latest version per pubkey is valid. Clients should:
- Always query for the most recent
- Replace cached versions when newer found
- Handle missing relay lists gracefully
Machine-Readable Summary
{
"nip": 65,
"title": "Relay List Metadata",
"status": "final",
"defines": [
"relay-list-format",
"read-write-markers",
"gossip-model"
],
"event_kind": 10002,
"replaceable": true,
"tag_format": ["r", "relay-url", "read|write?"],
"discovery_model": "outbox",
"related": [
"/learn/nostr/relays",
"/learn/nostr/specs/nip-01",
"/learn/nostr/specs/nip-02"
]
}