iMessage on Windows, Without a Mac
TLDR: My Mac got stolen, so I reverse-engineered Phone Link and combined three Bluetooth profiles (ANCS, MAP, PBAP) into an open-source Windows daemon that lets you read and send iMessages and SMS from your iPhone, without a Mac or jailbreak. The result is adit.
My Mac got stolen last week. The week before that, I'd been building a channel for OpenClaw, and it needed access to my texts to work. I have an iPhone and use iMessage constantly. I assumed iMessage without a Mac was impossible, but I kept digging.
Every existing solution assumes you have a Mac. BlueBubbles runs a server on macOS that talks to Messages.app. AirMessage does the same thing. OpenClaw's iMessage channel routes through a Mac host. The reason is simple: Apple locks iMessage inside its own frameworks. There's no public API, no protocol you can speak from another platform. If you don't have a Mac, you don't get iMessage. That's what everyone says, and it's mostly right.
The one exception I could find was Phone Link, which gets partial iPhone access without a Mac (notifications and SMS, not real iMessage) but it's flaky, closed, and limited. I decompiled it to find out why it works at all.
It was using three separate Bluetooth profiles: ANCS for notifications, which includes iMessage previews; MAP for message access, which covers reading and sending SMS; and PBAP for contacts. None of those gives you full iMessage, and none of them is enough by itself, but together they give you notifications, names, numbers, read access, and a send path for the parts Apple still exposes. I couldn't find a public project that combined them into something open and programmable. Microsoft had already combined them inside Phone Link. I wanted the open version.
Two days later, with help from GPT 5.4 and Claude Opus 4.6, I had a Windows daemon pulling iMessage notifications, reading and sending SMS, syncing contacts, and exposing the result over a local API.
That still did not produce a true Apple-native iMessage client. What it gave me was a Windows messaging layer: SMS read/send, iMessage notification visibility, contacts, and enough local reconstruction to be useful.
I open-sourced it as adit: a local Windows daemon with a REST API, WebSocket stream, and thin JS/Python/MCP SDKs on top.
Phone Link Was the Spec
The first thing I wanted to know was whether Microsoft had some private path to the iPhone that I didn't. If Phone Link depended on a secret Apple API or some entitlement normal apps can't get, the project was dead before it started.
I decompiled it with ILSpy: YourPhone.Connectivity.Bluetooth.Managed.dll, YourPhone.YPP.Bluetooth.dll, and a few related assemblies. What I found was reassuring. Phone Link wasn't doing anything magical. It was using the same Windows Bluetooth APIs I could call, talking to the same profiles every iPhone already exposes. The ceiling I could hit was the same ceiling Microsoft was already working under.
ANCS was the first one I chased. Windows could see the service on my iPhone, but every attempt to subscribe to its characteristics died at AccessDenied. I tried the obvious fixes (Developer Mode, package identity, Bluetooth capabilities) and none of them changed it.
The fix was a single call I found in Phone Link's decompiled code: GattDeviceService.OpenAsync(). After I added it, the ANCS path opened up immediately and live iMessage notifications started streaming into a Windows terminal.
How OpenAsync unlocked ANCS
Phone Link's ANCS path really was this boring. The runtime version in Adit.Core now does the same two-step open:
var access = await WaitAsync(service.RequestAccessAsync(), WinRtOperationTimeout, cancellationToken);
var openStatus = await WaitAsync(service.OpenAsync(GattSharingMode.SharedReadAndWrite), WinRtOperationTimeout, cancellationToken);
if (openStatus is GattOpenStatus.Success or GattOpenStatus.AlreadyOpened)
{
return true;
}
The earlier probe did the same thing more directly:
var serviceAccess = await service.RequestAccessAsync();
var openStatus = serviceAccess == DeviceAccessStatus.Allowed
? await service.OpenAsync(GattSharingMode.SharedReadAndWrite)
: GattOpenStatus.Unspecified;
Once the handle was open, the probe could bind all three ANCS characteristics and log ancs.subscriptions_ready.
The UUIDs were:
- Service:
7905F431-B5CE-4E99-A40F-4B1E122D00D0 - Notification Source:
9FBF120D-6301-42D9-8C58-25E699A21DBD - Control Point:
69D1D8F3-45E1-49A8-9821-9BBDFDAAD9D9 - Data Source:
22EAC6E9-24D6-4BB5-BE44-B36ACE7C7BFB
The Notification Source payload is only 8 bytes:
[event_id][flags][category][count][notification_uid:u32_le]
The attributes I asked for by default were:
AppIdentifierTitle(64)Subtitle(64)Message(256)MessageSizeDatePositiveActionLabelNegativeActionLabel
That was enough to prove the useful part of ANCS: app id, sender/title, subtitle, preview text, message size, timestamp, and action labels. In the live daemon, that same path was later proven end to end for notification receive plus action execution, which is why the repo now treats ANCS as a first-class transport instead of a one-off probe.
Taking Over the MAP Session
ANCS gave me notifications, but notifications aren't messages. For real SMS read and send, I needed MAP, the Bluetooth Message Access Profile, an older standard that runs over classic Bluetooth instead of BLE.
Inside Phone Link's installed package were private Microsoft DLLs that already implemented the full MAP stack: Microsoft.Internal.Bluetooth.Map.dll, Microsoft.Internal.Bluetooth.Obex.dll, Microsoft.Internal.Bluetooth.Pbap.dll. They aren't documented and they aren't part of any public SDK. I loaded them with reflection.
Phone Link's background processes (PhoneExperienceHost.exe and CrossDeviceResume.exe) were already holding the MAP path open. The iPhone only accepts one MAP client at a time, so the fix was blunt: kill both processes before opening the session.
After that, MAP opened on the first try. PushMessageAsync came back Success, and the SMS showed up on my iPhone a few seconds later.
Loading Microsoft's private DLLs and taking over the MAP session
The actual eviction helper was tiny:
private static readonly string[] ProcessNames =
[
"PhoneExperienceHost",
"CrossDeviceResume"
];
It walks those processes, kills them, waits for exit, and logs map.processes_evicted.
The send path was just as direct once the session was mine:
var result = await client.PushMessageAsync(
MapClientInterop.CreatePushMessageRequest(recipient, body),
traceContext,
cancellationToken);
The probe version did the same thing:
var pushResult = await mapClient.PushMessageAsync(
CreatePushMessageRequest(options.Recipient, options.MessageBody),
CreateTraceContext(),
cancellationToken);
The useful facts MAP gave me were:
- folder listing worked
- inbox fetch worked
- full message body fetch worked
- send worked
- long-lived
MNSevent flow worked
The useful limits were just as important:
- the iPhone only exposed
inbox,sent,outbox, anddeleted - live SDP inspection reported
MAP 1.4 - every message body I got back was typed
SMS_GSM - even when the same MobileSMS content also showed up through ANCS, MAP flattened it into SMS-shaped rows with no Apple conversation IDs or real group metadata
That is why the post is so blunt about the boundary. MAP was real, but in my captures it never exposed anything richer than SMS-shaped records.
The limitation showed up immediately. Every message MAP returned was typed SMS_GSM. Even when the same MobileSMS content also showed up through ANCS, MAP flattened it into SMS-shaped rows with no Apple-native thread or iMessage metadata. ANCS knew about iMessages as notifications. MAP gave me durable message bodies, but not a real iMessage surface. That gap defined most of the hard problems later.
The third profile was PBAP (Phone Book Access Profile) for contacts. Microsoft had a private DLL for that too. I loaded it the same way, pulled 95 contacts off my iPhone, and wrote a PhoneNumberNormalizer so +18002752273, (800) 275-2273, and 8002752273 all resolved to the same person. Without contacts, everything else is just raw phone numbers.
95 contacts and a phone number normalizer
The probe asked for the phonebook as telecom/pb.vcf in VCard21 format:
var contactsResult = await InvokePbapAsync(
client,
"PullPhoneBookAsync",
new PullPhoneBookRequestParameters
{
ListStartOffset = 0,
MaxListCount = 200,
Name = "telecom/pb.vcf",
Format = RequestFormat.VCard21,
Filter = AttributeMask.FormattedName
| AttributeMask.StructuredName
| AttributeMask.PhoneNumber
| AttributeMask.EmailAddress
| AttributeMask.UID
},
cancellationToken);
On my phone that returned 95 contacts, which was enough to build a usable local number index.
The normalizer was intentionally boring:
var hasLeadingPlus = trimmed.StartsWith('+');
var digits = new string(trimmed.Where(char.IsDigit).ToArray());
if (hasLeadingPlus)
{
return $"+{digits}";
}
if (digits.Length == 11 && digits.StartsWith(defaultCountryCode, StringComparison.Ordinal))
{
return $"+{digits}";
}
if (digits.Length == 10 && !string.IsNullOrWhiteSpace(defaultCountryCode))
{
return $"+{defaultCountryCode}{digits}";
}
That one step was what let MAP addresses, PBAP cards, and ANCS sender hints land on the same person instead of exploding into duplicate identities.
With all three profiles working independently, the protocol side was proven. The next problem was turning those three partial views into one messaging system.
The Hard Part Was Threads
Getting data off the phone was only half the job. None of these channels gave me a real conversation model. ANCS had iMessage previews but no bodies. MAP had durable message bodies, but only in SMS-shaped records with no real thread structure. PBAP had names and numbers but no message context at all. If I wanted something an agent could actually use, I had to build the conversation layer myself.
That meant inventing stable local thread IDs, matching ANCS previews against later MAP rows, dealing with group messages that showed up differently across channels, and deciding when two fragments were really the same message. Bluetooth gave me slices. It didn't give me a messaging system.
The core of it was a ConversationSynthesizer that normalized MAP rows, ANCS notifications, and PBAP contact data into one observation stream, tried to resolve which fragments referred to the same people and messages, clustered them into candidate conversations, and assigned stable local thread IDs that survived daemon restarts.
Group chats were the worst part. MAP gave me no usable group model, and for iMessage groups it was basically blind. The only reliable hints came from ANCS notification subtitles, which sometimes included a group name or participant list. I was inferring group structure from notification metadata.
ANCS also created what I started calling shadow messages. An iMessage notification would come in through ANCS with a sender and preview text, but there'd be no matching durable row in MAP with the same structure because MAP only gave me SMS-shaped records. So the synthesizer would create a provisional message from the ANCS data alone, and if a matching MAP row showed up later, it would promote and merge them. If it didn't, the shadow stayed as the only evidence that message existed.
How shadow messages get promoted and merged
The repo eventually made this explicit: the daemon issues stable local IDs like th_... and msg_... because Apple does not expose durable conversation IDs on this stack.
The synthesizer did more than cluster text. It had to:
- normalize MAP, ANCS, PBAP, and send-intent evidence into one comparable shape
- resolve whether a phone number, sender name, or clipped notification title referred to the same person
- keep stable thread continuity across daemon restarts
- merge durable MAP rows with provisional ANCS shadow rows without double-counting
The shadow merge code is a good example of the style. It refuses to merge rows that are too far apart, then looks for preview match, sender match, and participant overlap:
var delta = CalculateMessageDelta(shadow.SortTimestampUtc, durable.SortTimestampUtc);
if (delta is null || delta > TimeSpan.FromMinutes(20))
{
return int.MinValue;
}
var previewMatch = PreviewsLikelyMatch(shadowPreview, durablePreview)
|| ReactionSemanticsLikelyMatch(shadowPreview, durablePreview);
var senderMatch = MessagesLikelyShareSender(shadow, durable);
var participantOverlap = CountParticipantOverlap(shadow.Participants, durable.Participants);
Then it scores the merge candidate harder if the timestamps are close, the sender lines up, and the participant set overlaps.
When the merge wins, the durable row keeps the stronger structure while inheriting useful ANCS detail:
return durable with
{
ConversationDisplayName = durable.IsGroup
? durable.ConversationDisplayName
: shadow.ConversationDisplayName.Length > durable.ConversationDisplayName.Length
? shadow.ConversationDisplayName
: durable.ConversationDisplayName,
IsGroup = durable.IsGroup || shadow.IsGroup,
Participants = mergedParticipants,
Message = durable.Message with
That's basically the whole project: keep the durable thing when you have it, but steal every useful hint you can from the weaker channel.
The synthesizer grew to about 4,000 lines. It handled a lot of cases. It also kept breaking.
When Heuristics Stopped Working
The bug that made me rethink the whole approach was a group thread. Direct messages from one person kept getting grouped into a group chat they were also in. The synthesizer would split them correctly on a fresh run, but after a daemon restart with persisted thread history, the direct conversation and the group would collapse back into one thread. I'd fix the rule, run the tests, watch them pass, restart the daemon on live data, and the same messages would end up in the wrong place again.
The root cause kept shifting. First it was a continuity bug: two conversations claiming the same historical thread ID. I fixed that. Then it was a sender-overlap issue: mom appeared in both the group and the direct thread, and the synthesizer couldn't tell the difference with low message counts. I fixed that. Then it was a scoring edge case where blank and reaction-only messages were inflating continuity scores. Every fix uncovered a new failure mode.
That's when I realized the problem wasn't any individual rule. The synthesizer was 4,000 lines of heuristics that worked on most inputs but couldn't generalize to the edge cases that actually mattered. Every new rule I added to fix one case risked breaking three others. Phone Link has the same problem. Its thread assignment is still broken, with users reporting messages landing in wrong conversations and phantom group chats for years. The abstraction was wrong. Thread assignment wasn't a rules problem. It was a classification problem.
I built a learned chooser alongside the heuristic core: a frozen Qwen encoder for the text, plus a small chooser model on top that ranked candidate threads. I trained it on synthetic MAP and ANCS fragments generated with Claude Opus, including the same kinds of corruption that kept breaking the rule-based system: bad timestamps, missing fields, ambiguous sender overlap, stale thread history.
The listwise approach was important. The model didn't score threads in isolation. It scored 3–8 candidate threads against each other, so it could learn that a message fit thread A better than thread B even when both looked plausible. That's the kind of judgment the heuristics couldn't express.
I didn't want to hand-wave the pivot, so I built evals for it. Qwen didn't solve threading by itself. What the evals showed was that a semantic branch, fused with the structural features I already had, bought the last ten points on the hard benchmark without regressing on the real anchors.
The eval numbers and why the fused model won
The main table is the clean comparison that mattered:
| Benchmark | Samples | Candidates | Fused top-1 | Structural top-1 |
|---|---|---|---|---|
| Real eval anchor | 60 | 3-8 | 100.0% | 96.7% |
| Semantic-stress real slice | 87 | 3-8 | 98.9% | 95.4% |
| Challenge holdout (closed-set) | 120 | 4-6 | 83.3% | 73.3% |
The challenge holdout also had a 40-sample open-set companion, but the current chooser did not have an explicit reject/new-thread head yet, so I did not use that as a headline metric.
The hard holdout was deliberately nasty:
reaction_or_quote:0.15stale_gold_vs_recent:0.3417generic_or_short_or_missing_text:0.5333same_sender_or_shared_active_participant:0.8083anaphora_or_pronoun:0.25dm_vs_group:0.9333
That was the first benchmark in the project where the frozen-Qwen branch was clearly earning its keep. The lift over structure concentrated in exactly the ambiguity classes I cared about:
stale_gold_vs_recent:+0.1707reaction_or_quote:+0.2222dm_vs_group:+0.1250same_sender_or_shared_active_participant:+0.1134anaphora_or_pronoun:+0.1000generic_or_short_or_missing_text:+0.0938
Semantics alone was still not enough, which is why the final system stayed fused instead of becoming "just use Qwen." A same-checkpoint zero-structural ablation of the v3plus fused model collapsed to:
6.7%top-1 on the 60-sample real anchor21.8%top-1 on the 87-sample semantic-stress slice
That sounds harsh, but it is the correct lesson. This was never a pure language problem. Structure carried the base load; semantics helped exactly where structure tied or lied.
The training setup that actually mattered was:
- frozen Qwen-family semantic branch (the best current opt-in checkpoint uses
Qwen/Qwen3-1.7B-Base) - structural features from MAP / ANCS / PBAP weirdness
- listwise chooser over whole candidate sets
- no
candidate_scorein the default headline model - explicit leakage guards: no target display-name shortcut in the semantic path
That combination is what let the model help without just compressing the old heuristic score back into another number.
Footnote: the learned chooser is currently kept as an explicit opt-in in the repo rather than the silent default. In messaging, one misrouted message is bad enough that strong offline and replay numbers still are not enough by themselves, and the chooser also adds Python sidecar plus model overhead on updates.
What I Ended Up Shipping
The shipping system is a Windows daemon that holds MAP, ANCS, and PBAP sessions open, fuses them into stable threads, and exposes the result over a local API. Thin Python and JavaScript SDKs sit on top so nothing downstream touches Bluetooth. The whole stack runs on stock hardware with no Mac, no jailbreak, no relay server.
It still isn't a true iMessage client. iMessages arrive as notifications, SMS has the real send/read path, and group structure is inferred locally. But even in that shape, it turns an iPhone into a programmable messaging surface from Windows, which is what I needed.
What I Learned
Agent software is going to be bottlenecked less by models than by ugly real-world integrations. Getting an LLM to write a reply is easy. Giving it a reliable, programmable view of who texted you, what they said, and whether it can respond is the hard part. Most of the interesting work is in those missing surfaces.
I also came away respecting Phone Link. The reverse engineering was clarifying. Microsoft had already found most of the same hard boundaries I did. They shipped a closed product on top of them; I wanted a programmable one.
If you want to talk about this, email me at [email protected]. Source is on GitHub. If you'd rather text, Phone.