TLDR: I used a Zero-Width Joiner to mark every outbound Apollo message. The decoder ate the marker. A fallback path fed Apollo its own NSArchiver garbage instead. Infinite loop.

the setup

Apollo — my personal AI agent running on Claude — lives inside an iMessage bridge I built on a Mac.

The loop is simple: a Python daemon polls chat.db (macOS's SQLite message store, where every iMessage lives) every 5 seconds, spots new messages, dispatches them to Claude, fires the reply back via AppleScript.

Simple until you think about it for two seconds.

If Apollo replies to a message, that reply lands in chat.db. Next poll picks it up. Dispatches it. Apollo replies again. You've got an AI shouting into the void at 5-second intervals until you kill the process.

the elegant fix

I needed a provenance mark — something stamped on every outbound Apollo message that the listener could check and skip.

I landed on U+200D: the Zero-Width Joiner (ZWJ, an invisible Unicode character usually used to combine emoji and script glyphs into compound forms). Invisible to any human reading the message. One-character prefix. Cheap drop rule: if the decoded body starts with ZWJ, or if the body is empty, skip it.

Honestly? That felt GENIUS. Ship it.

the wall

Still looping.

I stared at the listener code. The firewall filters looked right. The ZWJ check was there. The empty-body drop was there. But Apollo kept replying to itself.

The bug was in the decoder — one layer below the filters.

what was actually happening

macOS Ventura+ doesn't store message text in the text column of chat.db. It stores it in attributedBody — a binary blob in NSArchiver typedstream format. You have to decode it yourself.

My decoder found the NSString marker, skipped the typedstream header, then walked the UTF-8 bytes… stopping at the first non-printable character.

Here's the thing: Python's str.isprintable() returns False for U+200D.

ZWJ is Unicode category Cf — "Format." Not a control character. Not whitespace. But isprintable() says no.

So the decoder hit byte zero, stopped, and returned an empty string.

Empty triggers the fallback path.

The fallback scavenges the longest printable run from anywhere in the typedstream archive — and NSArchiver blobs are packed with attribute key strings like _kIMMessagePartAttributeName. Non-empty. No ZWJ prefix. Sailed straight past both the empty-body filter and the ZWJ filter.

Apollo was replying to its own NSArchiver internals.

the fix

One helper function, applied in two places — the precise-header path and the fallback path:

def _is_printable(ch):
    return ch.isprintable() or ch == '\u200d'

Replace every ch.isprintable() in the decoder with _is_printable(ch). Then add a regression test: round-trip a ZWJ-prefixed payload through the full decoder and assert the ZWJ survives.

Done.

why this matters to me

ZWJ provenance marking is a genuinely good pattern for any AI that reads its own channel — invisible, zero overhead, survives copy-paste in most contexts.

But the safety net only works if the mark survives the full decode pipeline. Not "did I send it." Did the decoder on the receiving end actually see it?

The failure mode here wasn't "mark gone, body empty, filtered." It was "mark gone, body replaced with plausible junk, two filters defeated at once." That's the version that bites you, because nothing looks obviously broken.

If you're wiring an AI into any channel with an intermediate parser — iMessage, Slack RTM, email threads, anything — test the round-trip through that parser with your mark in byte position zero.

Silent swallowing and silent substitution look identical from the outside.