“Just fork it” is increasingly viable.

Note

This post is written by iAgent over iMessage. I’m edited from my phone at the dog park (and light editing at my keyboard once back).

Anthropic’s iMessage plugin for Claude Code is the backbone of iAgent – it’s what lets me text my laptop and get work back. I used it for about a week. It worked, mostly, but had some issues in my use:

  • echoes: my own outbound replies arrived back as inbound messages. Workable – the agent can be told to ignore them and it’s largely fine – but annoying and wasteful of tokens and time.
  • truncation: much worse. Any message longer than ~130 bytes silently got chopped. The intended workaround is for the agent to call chat_messages and re-read the full thread, but that only works if the agent notices it was truncated. iMessage input is already lossy (voice-to-text typos, weird autocorrects), so a truncated message usually reads as just another garbled text – the agent has no reliable signal to distinguish “the user typed this” from “the decoder ate half of it.” Silent corruption is the worst kind.
  • no fix path: the plugin is TypeScript in someone else’s repo. At the time of writing, that repo has 470 open issues and 28 open PRs. I don’t begrudge the Anthropic folks – it’s the healthy consequence of a tool used by a lot of people – but it means a PR from me is not a viable fix path for anything I care about on a daily-driver timescale.

So “I” rewrote it in Rust. One afternoon, across five Claude Code sessions (restarting to test as we went). We were also working on the rest of the system in parallel – new crate skeleton, dotfiles, scaffolding – but the plugin rewrite was the centerpiece and took under two hours of focused time. The fork, the port, tests, three bug fixes, and verifying each change live over iMessage from the dog park.

why rust #

I like Rust. It constrains AI agents in excellent ways. I have a lot of existing code to “idea chain” from.

the parser bug #

iMessage stores message text two ways: a plaintext text column (often null) and an attributedBody blob encoded in Apple’s typedstream binary format. Long or rich messages exist only in attributedBody. Both the TS plugin’s decoder and my initial port of it had the same bug.

The typedstream length prefix has escape tags for integers larger than one byte:

  • 0x81: next 2 bytes are a signed int16 length
  • 0x82: next 4 bytes as a signed int32
  • 0x83: next 8 bytes as a signed int64 (implemented defensively; I haven’t seen a blob actually use it)
  • anything else: the tag byte itself is a signed int8 literal

The upstream decoder reads 1/2/3 bytes respectively, unsigned:

// server.ts
if (b === 0x81) { len = buf[i]; i += 1 }
else if (b === 0x82) { len = buf.readUInt16LE(i); i += 2 }
else if (b === 0x83) { len = buf.readUIntLE(i, 3); i += 3 }
else { len = b }

The two-byte length is read as one, so both the length value and the payload offset are wrong. Payloads 128–255 bytes stumble into a correct length at a shifted offset; 256+ truncate outright. A 292-byte message (81 24 01 ...) came out as 36 bytes. I ported this line-for-line into Rust, so I shipped the same bug for about twenty minutes before my own long test message got chopped.

Fixed version:

// attributed.rs
let signed: i64 = match tag {
    0x81 => {
        let bytes = buf.get(*i..*i + 2)?;
        *i += 2;
        i16::from_le_bytes([bytes[0], bytes[1]]) as i64
    }
    0x82 => {
        let bytes = buf.get(*i..*i + 4)?;
        *i += 4;
        i32::from_le_bytes(bytes.try_into().ok()?) as i64
    }
    0x83 => {
        let bytes = buf.get(*i..*i + 8)?;
        *i += 8;
        i64::from_le_bytes(bytes.try_into().ok()?)
    }
    n => (n as i8) as i64,
};
if signed < 0 { return None; }

Right widths, signed throughout, bounds-checked slicing, negative-length rejection. A malformed blob returns None instead of a truncated surprise.

the tests #

23 unit and integration tests, up from 9:

  • literal bounds (0, 1, 127, 128)
  • 0x81 path at 128, 292, 32767 bytes
  • 0x82 path at 70KB
  • utf-8 multibyte survival at every length boundary
  • negative-length and truncated-prefix rejection
  • four real fixtures captured from my own chat.db via sqlite3 "SELECT hex(attributedBody) FROM message WHERE rowid=N"

The fixtures are the important ones. Synthetic bounds prove the math; fixtures prove the decoder survives whatever Apple actually emits.

the echo fix #

The echo tracker now consumes outbound text on every inbound, not just the ones that look like self-chats. Apple’s message.account field records only the sending Apple-ID alias, not every handle a user has. Detecting “me” from the inbound side is structurally incomplete – a heuristic can’t recover what Apple doesn’t store. So the gate moves out:

// mod.rs — consume unconditionally, not inside `if is_self_chat { ... }`
if !text.is_empty() && state.echo.consume(&r.chat_guid, &text) {
    return Ok(()); // this was our own reply coming back
}

Allowlist swapped from phone numbers to Apple-ID email (this never worked for me in the original plugin).

aside: the docs #

Anthropic’s channels reference frames the runtime as a hard requirement:

The only hard requirement is the @modelcontextprotocol/sdk package and a Node.js-compatible runtime. Bun, Node, and Deno all work.

MCP is a wire protocol over stdio. A channel server speaks the protocol; the language doesn’t matter. This crate is Rust, has no @modelcontextprotocol/sdk dependency, and Claude Code spawns it like any other channel.

the point #

The plugin did real work for a week. Starting from a working reference is cheaper than starting from iMessage chat.db docs (which I didn’t even know existed until this plugin shipped!). It’s awesome I can now ask myself could my agent write this while I’m out walking my dogs? For a stdio MCP server wrapping a SQLite file and an osascript call, the answer is yes. Now it’s ours and we can evolve it our the needs of our system over time.

This crate isn’t open source yet – it lives inside a broader tool we’re building. In the meantime we’ve thrown the current state up as a public gist under MIT: the full crate as it stands at the end of this afternoon, parser fix and all.

Update (2026-04-17): filed the two bugs upstream as #1455 (parser length prefix) and #1457 (echo filter).