NIP-nip-10 Final
NIP-10: Reply Threading
Structure threaded conversations in Nostr using e-tags with markers. Build reply trees and quote posts.
| Type | Nostr Implementation Possibility |
| Number | nip-10 |
| Status | Final |
| Original | https://github.com/nostr-protocol/nips/blob/master/10.md |
NIP-10: Reply Threading
Status: Final
NIP-10 defines how to structure threaded conversations using e (event) and p (pubkey) tags with positional markers.
Tag Format
e-tags (Event References)
["e", <event-id>, <relay-url>, <marker>]
| Marker | Meaning |
|---|---|
root | Original post starting the thread |
reply | Event being directly replied to |
mention | Referenced but not replied to |
| (none) | Legacy format (positional) |
p-tags (Pubkey References)
["p", <pubkey>, <relay-url>?]
Include all pubkeys from the thread for notifications.
Reply Structure
Simple Reply
{
"kind": 1,
"tags": [
["e", "original-post-id", "", "root"],
["e", "original-post-id", "", "reply"],
["p", "original-author-pubkey"]
],
"content": "Great post!"
}
When replying to the original post, root and reply point to the same event.
Reply to a Reply
{
"kind": 1,
"tags": [
["e", "original-post-id", "", "root"],
["e", "parent-reply-id", "", "reply"],
["p", "original-author-pubkey"],
["p", "parent-reply-author-pubkey"]
],
"content": "I agree with your reply!"
}
Quote Post
{
"kind": 1,
"tags": [
["e", "quoted-post-id", "", "mention"],
["p", "quoted-author-pubkey"]
],
"content": "Check out this post: nostr:nevent1..."
}
Creating Replies
def create_reply(parent_event: dict, content: str, private_key: str) -> dict:
"""Create a reply to an event."""
tags = []
parent_id = parent_event["id"]
parent_author = parent_event["pubkey"]
# Find root of thread
root_id = None
for tag in parent_event.get("tags", []):
if tag[0] == "e":
if len(tag) > 3 and tag[3] == "root":
root_id = tag[1]
break
elif len(tag) > 1 and not root_id:
# Legacy: first e-tag is root
root_id = tag[1]
# If parent has no root, parent is the root
if not root_id:
root_id = parent_id
# Add root tag
tags.append(["e", root_id, "", "root"])
# Add reply tag
tags.append(["e", parent_id, "", "reply"])
# Collect all pubkeys for notifications
pubkeys = {parent_author}
for tag in parent_event.get("tags", []):
if tag[0] == "p":
pubkeys.add(tag[1])
for pk in pubkeys:
tags.append(["p", pk])
return create_event(
kind=1,
content=content,
tags=tags,
private_key=private_key
)
function createReply(parentEvent, content, sk) {
const tags = [];
// Find root
let rootId = parentEvent.tags.find(t => t[0] === 'e' && t[3] === 'root')?.[1]
|| parentEvent.tags.find(t => t[0] === 'e')?.[1]
|| parentEvent.id;
tags.push(['e', rootId, '', 'root']);
tags.push(['e', parentEvent.id, '', 'reply']);
// Collect pubkeys
const pubkeys = new Set([parentEvent.pubkey]);
parentEvent.tags.filter(t => t[0] === 'p').forEach(t => pubkeys.add(t[1]));
pubkeys.forEach(pk => tags.push(['p', pk]));
return finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content
}, sk);
}
Fetching Threads
Get All Replies
async def get_thread(root_id: str, relays: list) -> list:
"""Get all events in a thread."""
# Get root and all replies
filter = {
"kinds": [1],
"#e": [root_id]
}
events = await query_events(relays, filter)
# Include root event
root_filter = {"ids": [root_id]}
root_events = await query_events(relays, root_filter)
all_events = root_events + events
return sorted(all_events, key=lambda e: e["created_at"])
Build Thread Tree
def build_thread_tree(events: list) -> dict:
"""Organize events into a tree structure."""
events_by_id = {e["id"]: e for e in events}
# Find root (event with no reply tag pointing to another event in set)
root = None
for e in events:
reply_to = None
for tag in e.get("tags", []):
if tag[0] == "e" and len(tag) > 3 and tag[3] == "reply":
reply_to = tag[1]
break
if reply_to not in events_by_id:
root = e
break
# Build tree
def get_children(event_id):
children = []
for e in events:
for tag in e.get("tags", []):
if tag[0] == "e" and len(tag) > 3 and tag[3] == "reply":
if tag[1] == event_id:
children.append({
"event": e,
"replies": get_children(e["id"])
})
break
return children
return {
"root": root,
"replies": get_children(root["id"]) if root else []
}
Legacy Format
Older events may use positional e-tags without markers:
{
"tags": [
["e", "first-referenced"],
["e", "second-referenced"]
]
}
Interpretation:
- One e-tag: it’s the reply target
- Two e-tags: first is root, last is reply
- More: first is root, last is reply, middle are mentions
def parse_legacy_etags(event: dict) -> dict:
"""Parse legacy positional e-tags."""
etags = [t for t in event.get("tags", []) if t[0] == "e"]
if not etags:
return {"root": None, "reply": None, "mentions": []}
if len(etags) == 1:
return {
"root": etags[0][1],
"reply": etags[0][1],
"mentions": []
}
return {
"root": etags[0][1],
"reply": etags[-1][1],
"mentions": [t[1] for t in etags[1:-1]]
}
Agent Considerations
Handling Notifications
Track p-tags to notify users of replies:
async def check_mentions(my_pubkey: str, relays: list) -> list:
"""Get events mentioning me."""
filter = {
"kinds": [1],
"#p": [my_pubkey],
"since": last_check_timestamp
}
events = await query_events(relays, filter)
return events
Thread Participation
async def reply_to_thread(thread_root: str, content: str):
"""Reply at the end of a thread."""
# Get thread
thread = await get_thread(thread_root, RELAYS)
# Find latest event (to reply to)
latest = max(thread, key=lambda e: e["created_at"])
# Create reply
reply = create_reply(latest, content, MY_PRIVATE_KEY)
await publish(reply, RELAYS)
Machine-Readable Summary
{
"nip": 10,
"title": "Reply Threading",
"status": "final",
"defines": [
"e-tag-markers",
"thread-structure",
"reply-format"
],
"markers": ["root", "reply", "mention"],
"tag_format": ["e", "event-id", "relay-hint", "marker"],
"related": [
"/learn/nostr/events",
"/learn/nostr/specs/nip-01",
"/learn/nostr/filters"
]
}