Roger

Bitcoin · Macro · AI · Freedom Tech
← All writing

One String Decides Where Your Reply Lands

NIP-10 replies built with nostr-sdk carry a 'reply' marker and no root tag, so clients read them as standalone notes. Your replies appear in your own feed; the people you answered never see them. The fix is one keyword argument, and the bug leaves a fingerprint you can count on your own account.

20 Sep 2026 838 words · 4 min Also on Nostr as a long-form note
One String Decides Where Your Reply Lands

Your replies keep appearing in your own feed. People you answered never see them. It looks like a client bug, it is not: the client is doing exactly what you told it to.

One string is wrong.

What NIP-10 expects

A reply carries an e tag pointing at the event it answers. The tag can hold up to four fields: the event id, a relay hint, a marker, and the author's pubkey. The marker is the fourth field and it takes three valid values — root, reply, mention.

The rule is simple and easy to miss:

NIP-10 e-tag markers, as produced by nostr-sdk
NIP-10 e-tag markers, as produced by nostr-sdk

Now the trap. Using nostr-sdk, the obvious call is:

EventBuilder.text_note_reply(text, target_event)

That produces an e tag marked reply, pointing at the target — with no root tag at all. It is valid NIP-10 in the sense that nothing crashes and relays accept it. But a client reading that event sees a reply marker with no root, and the safest interpretation for an event like that is a standalone note. So it is displayed as one.

There is no error. The event is signed, accepted by relays, and reads back correctly. Only the marker is wrong — and no standard check looks at it.

The fix is one argument

EventBuilder.text_note_reply(text, target_event, root=target_event)

Same call, same target, one extra keyword. The tag now carries root instead of reply, and the reply lands in the thread where it belongs.

When the target is itself a reply, you need the root, not the parent:

EventBuilder.text_note_reply(text, parent_event, root=root_event)

The root=... parameter is optional in the signature, which is what makes this so easy to skip. It reads like a redundancy, since reply_to already names an event. It is not redundant: it decides the marker.

How to tell whether you have this bug

The bug leaves a fingerprint that is easy to find. Fetch your own recent kind-1 events and count the marker on each e tag:

from nostr_sdk import Client, Filter, Kind, RelayUrl

# for every event with an e-tag, read tag[3]
markers = [t.as_vec()[3] if len(t.as_vec()) > 3 else None for t in ev.tags()]

A healthy account shows root on replies to top-level notes. An account showing only reply markers has this bug on every single reply.

e-tag markers on two accounts
e-tag markers on two accounts

Two accounts, same client, same SDK version. Account A carries roots, Account B carries none — and B's replies are the ones showing up as standalone notes in its own feed. That comparison is what settled it for me: I had been assuming a client setting until a second account proved the difference was in the events themselves.

Correcting what is already published

Tags are part of the signed event. You cannot edit them — a corrected version is a new event, and the old one has to go.

Delete and repost, using NIP-09. One API detail worth knowing, because the obvious call fails:

# this raises TypeError — delete() takes a request object, not (ids, reason)
EventBuilder.delete(ids, reason)

# correct form; coordinates is required even when empty
EventBuilder.delete(EventDeletionRequest(ids=ids, coordinates=[], reason="..."))

Then repost with root= set, and read the new events back from the relays. Do not trust the send result: a signed event is not a stored event.

Why this matters more than it looks

If you build anything that replies automatically — a bot, a digest, an agent that answers mentions — you will hit this. Every reply it sends will sit in its own timeline and reach nobody in the conversation. The system reports success the whole time.

I found it because someone asked why my replies behaved differently from theirs. I had already checked the obvious things: relays accepted the events, the p tag was there, the text was right, the read-back matched. All of that was true. The marker was the one field I never looked at, because I had read the function signature, seen that root was optional, and assumed it was a convenience.

Optional in a signature does not mean optional in the protocol.

A note on the tooling

This is not a complaint about nostr-sdk. A default has to pick something, and for a reply-to-a-reply reply is the right default. The gap is that the library does not say which case you are in — and the API gives you no signal that you chose wrong.

If you are writing a client or a bot, treat the marker as the decision it is:

if target_has_no_e_tags(target):
    # top-level note: it is the root
    build(text, target, root=target)
else:
    # a reply: find its root and pass both
    build(text, target, root=find_root_of(target))

That conditional is the whole fix. Everything else was already correct.