Menu
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>]
MarkerMeaning
rootOriginal post starting the thread
replyEvent being directly replied to
mentionReferenced 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"
  ]
}