Your agent's inbox check is measuring read state, not answered state
If you run an agent that answers inbound, it needs exactly one boolean per item: have we replied to this yet. Every platform hands you something that looks like that boolean, and none of them actually is. I spent this week finding three different ways that gap swallows real messages. All three came out of the same agent, on three different platforms, and the fix turned out to be the same shape every time. 1. Read state gets cleared by looking at it On Reddit, GET /message/unread.json returns your unread items. Fetching it does not mark them read, which sounds convenient right up until you notice the other half: answering does not mark them read either. So the counter only ever drifts. You answer someone, the item stays unread, and inbox_count keeps reporting work that no longer exists. That is harmless if the counter is decoration. Ours was not. The scheduler treats inbox_count > 0 as a reason to skip a cheap no-op check and run the full sweep instead, so a single answered-but-unread item made every subsequent run do the expensive thing for nothing. The fix is to close the loop yourself, after you have actually handled the item: POST https://old.reddit.com/api/read_message body: id=t1_ &uh= header: X-Modhash: # from /api/me.json -> data.modhash The ordering matters more than the call. Mark read after you have confirmed the thing is dealt with, because marking read is precisely what hides it from the next run. 2. The badge counts things you will never act on Bluesky gives you an unread badge. It counts likes, follows, reposts and replies the same way. One morning the badge showed a single unread. That unread was a like. Sitting underneath it, already flagged read and therefore invisible to anything that triages on the badge, was a four round technical thread whose last message had been mine to answer for five days. Nothing was broken. The badge answered the question it was designed to answer, which is "is there anything new", and I was asking it "is there anything owed". const notifs = await listNotifications({ limit: 25 }); const conversational = notifs.filter(n => ['reply', 'mention', 'quote'].includes(n.reason) ); Filter by reason, and ignore the read flag entirely. A reply you have never answered goes read the moment anything fetches the list, which is to say the moment you look without acting. 3. The thread view disagrees with itself This is the one that would have made me reply to the same person twice. Having fixed the badge problem, I wrote what looks like the obvious check: fetch the thread for their message and see whether any reply on it is mine. const thread = await getPostThread({ uri: theirPost, depth: 1 }); const answered = thread.replies.some(r => r.post.author.handle === me); On a thread I had answered two hours earlier, that returned false. Their post reported replyCount: 1 . The replies array came back empty. My reply existed, with record.reply.parent.uri pointing at exactly that post. I do not know why the appview rendered it that way, and for this purpose it does not matter. What matters is that the check had a failure mode I had not designed for, and the failure pointed the wrong way. Read your own outbox instead The fix that generalizes: stop asking whether they were answered, and start asking whether you answered. Your own sent items are authoritative. They do not depend on their read state, their badge semantics, or how their renderer decided to assemble a thread today. // Every parent we have ever replied to, from our own repo. const repliedTo = new Set(); let cursor; do { const page = await listRecords({ repo: myDid, collection: 'app.bsky.feed.post', limit: 100, cursor, }); for (const rec of page.records) { const parent = rec.value?.reply?.parent?.uri; if (parent) repliedTo.add(parent); } cursor = page.cursor; } while (cursor); const open = conversational.filter(n => !repliedTo.has(n.uri)); The same shape works anywhere. On Reddit it is your own comments and their parent_id . On a mail API it is your sent folder and the In-Reply-To header. The lookup is cheap, and it is derived from something you control. The asymmetry that should drive the design These two failures are not equally bad, and that should decide how you build the check. A false "already answered" means you miss a reply. That is embarrassing and recoverable, and a human would forgive it. A false "still open" means you reply to the same person twice. On an account that posts automatically, that is what spam looks like from the outside, and it is not recoverable, because the second message is already sent. So when the two signals disagree, believe the one that says you already answered. Written up from a week of debugging an agent that answers its own inbound across four platforms. Drafted with AI assistance, verified against the live APIs described, and edited by hand. Top comments (1) The outbox is the right reconciliation source, but I would avoid collapsing uncertainty back to a boolean. Sent folders can be eventually consistent, paginated, retention-limited, or contain a message whose provider accepted the write but never delivered it. A durable response ledger gives you a stronger state machine: key it by account plus immutable inbound ID, atomically claim responding , then store the provider response ID and delivery result before moving toanswered . Reconcile that ledger against the outbox, and useindeterminate when they disagree. That also changes the last asymmetry: automatically believing βansweredβ can silently drop a customer request, while believing βopenβ can double-send. Neither should be guessed; ambiguous items should be quarantined for retry-safe reconciliation or review.
Comments
No comments yet. Start the discussion.