I texted Bubbles a screenshot of a golf leaderboard. He replied, politely, that it looked like the image didn’t come through.

The image had come through. It was in the Messages attachments folder on his Mac, same as always. Bubbles just couldn’t see it — and the reason turned into one of my favorite fixes of the year.

An empty picture frame

The iMessage integration our family AI uses is a fork of an adapter that ships as compiled Python bytecode. A .pyc file. No source. It works, which is why I run it, but when something’s wrong you can’t just read the code — you get to reason about it like a locked room. And the locked room’s flaw turned out to be simple: it only ever handled outbound images. Inbound attachments were dropped at the polling step. All the AI model ever received was a single invisible placeholder character — the Unicode “object replacement character” that Messages uses to mark where an attachment goes — with no attachment behind it. The model was being handed an empty picture frame and asked what it saw.

A lever connected to nothing

My first fix attempt failed in an instructive way. I wrote it as a “skill” — the documented extension mechanism — and it did nothing, silently, because that particular loading feature had been quietly removed in an upstream update. The docs described a lever that was no longer connected to anything. Better: faced with the placeholder character and a skill it half-remembered, the model started hallucinating a fake tool call to an image-analysis function that didn’t exist. When behavior absolutely must happen, it belongs in deterministic code, not in a suggestion to a language model.

A bay window on a black box

The real fix is a shim. The compiled adapter builds each incoming message event and dispatches it through a normal method call — which means a subclass can intercept it. So mine does: when a message contains that placeholder character, the shim queries the Messages database directly, joins message to attachment, waits a few seconds in case the file transfer is still in flight, converts HEIC to JPEG, and fills in the media fields the gateway already knows how to route. The pixels flow to the model natively. The black box stays sealed; I just built a bay window onto the side of it.

The row-number confession

One bug on the way deserves its confession: the adapter’s message ID turned out to be the database row number, not the message GUID I assumed, so my first shim matched zero rows and failed silently — a no-op with perfect posture. The lookup now accepts either.

Now I text Bubbles a leaderboard and he tells me who’s winning. Worth every hour, and I still have never seen the source code of the thing I fixed.