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 2026838 words · 4 minAlso on Nostr as a long-form note
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:
If you answer a top-level note, that note is the thread root. The tag must be marked root.
If you answer a reply, both tags belong in the event: the thread root as root and the immediate parent as reply.
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 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 onlyreply markers has this bug on every single reply.
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.