In April 2026 a Macintosh emulator running System 7.5 answered an HTTP request. In July 2026 a physical Macintosh SE/30 from 1989 — no network card, one serial cable — showed an AI agent its own screen, its own disk, and let itself be driven by the mouse. Between those two points lie six months, sixty-one documented sessions, four freezes that locked an entire operating system, and one bug that only real silicon could reveal. This is that story, assembled from the project’s own record.

Why this is worth telling
There is a genre of retrocomputing article that ends at “and then it booted.” This is not one of them.
The interesting claim here is narrower and stranger: a toolchain designed in 1986, for a machine with 24 address lines and a cooperative scheduler, turns out to be remotely drivable by a language model — and the reason has nothing to do with AI. It has to do with a design decision made forty years ago, for entirely different reasons, that happened to leave the right door open.
But latent is not the same as working. Between “architecturally possible” and “answers on a Tuesday afternoon” sits the bridge. Building it is the story.
The machines at both ends
One end is a modern laptop running an agent. The other end varies, and the variation is the point.
For most of the project the far end was Basilisk II — an open-source emulator of Motorola 68000-series Macintoshes, first released in 1999 by Christian Bauer, who had previously written the Commodore 64 emulator Frodo and the Amiga-hosted Mac emulator ShapeShifter (Wikipedia). Faithful, but software pretending to be hardware.
The real machine that eventually answered was a Macintosh SE/30: introduced on 19 January 1989 at US$4,369, a Motorola 68030 at 15.667 MHz with a 68882 FPU, a 512×342 monochrome display, and expansion to 128 MB of RAM. It is widely regarded as the fastest and most expandable monochrome compact Mac — and by a good many enthusiasts as the best Mac ever made (Wikipedia, EveryMac).
It also carries a detail that ties this entire story back to the 24-bit article: the SE/30 shipped with “32-bit dirty” ROMs, so it could originally address only 8 MB of physical RAM no matter how much was installed (Apple Wiki). The machine at the far end of this bridge is itself a monument to the flags-in-the-high-byte problem.
Phase one: the wrong architecture, usefully
The earliest entry in the record is dated 2 April 2026, and it is not a bridge at all. It is a native THINK C application for System 7.5 — a Mac Toolbox window with two TextEdit fields, a menu bar, four buttons, and an HTTP client written against MacTCP that polls a server every two seconds and parses JSON by hand.
The server detected its client by User-Agent and answered either JSON or HTML, so that Netscape 3 kept working as a fallback.
It worked. It was also the wrong shape, and the comparison table in that early note quietly says why: the browser path suffered META REFRESH flicker, three-step copying, ISO-8859-1 encoding where MacRoman was needed. These are the complaints of someone driving a machine through a web page rather than talking to it.
The insight that ends phase one is architectural: don’t put a client on the Mac. Put a daemon there.
Phase two: the reversal
The emulated Mac sits behind NAT. Nothing can dial in. The obvious architecture — server on the Mac, client on the host — is dead on arrival.
So the direction inverts. The Mac dials out.
flowchart LR
A["Claude Code
agent"] --> B["MCP server
20 tools"]
B -->|":9001"| C["host_server.py
relay"]
C -->|":9000"| D["daemon 'ABrg'
System 7"]
D -->|"Apple Events"| E["ToolServer / MPW"]
E -->|"SC · Asm · Link"| F["68k binary"]
D -.->|"guest dials OUT first"| C
By April 8 the architecture is written down: a Swift host application holding two ports, :9000 for the daemon and :9001 for the control surface, because the Python process could not get screen-recording permission and both halves were fighting over the same port.
The wire protocol is deliberately blunt. A request is a command with a declared length; a reply is a length-framed STATUS/STDOUT/STDERR block, read by byte count, not by terminator. That single decision is what lets multi-kilobyte compiler listings survive the trip intact.
And on the Mac side, commands arrive as Apple Events dispatched to ToolServer rather than the MPW Shell. This matters more than it sounds: the Shell executes a command but returns an empty reply. ToolServer returns real stdout. Without it the agent is working blind, and the compile-read-error-fix loop cannot close.
Phase three: four ways to freeze a Macintosh
Here the record turns into a debugging journal, and it is the most instructive part of the whole project.
Freeze one — an IP address on the wrong interface
The setup had worked for weeks. Then the daemon began displaying “Active — CONNECTING” forever, the host CPU pegged at 100 %, and the entire emulated Mac locked — no Finder, no switching, nothing.

Three plausible suspects were wrong. Basilisk II was producing crash reports that morning, but for an unrelated macOS window bug; the freeze itself generated no crash report at all, because the process was alive and spinning. macOS stealth mode looked ideal as a culprit and changed nothing when disabled. And the guest would not answer a ping — alarming until the realisation that a machine behind MACNAT is never pingable by design, which is precisely why the daemon dials outward.
Two real clues cracked it. The host log recorded a connection arriving from 192.168.3.213 when the guest was configured as .244 — something was rewriting the source address. And the daemon’s display froze on the exact string set immediately before a synchronous, blocking OTConnect with no SystemTask yield.
A handshake that never completes →
OTConnectblocks forever → the cooperative scheduler starves → the whole machine freezes at 100 % CPU. What looked like a crash was a socket waiting for a SYN-ACK that never came.
The root cause was one address on one wrong interface. The guest NAT’d out through Wi-Fi; the host address the daemon dialled had been aliased onto the wired interface. The reply went to the wrong place and was swallowed by the host’s own network stack.
Freezes two, three and four — hardening the daemon
Fixing the network removed the trigger but left the landmine: any blocking connect can wedge the machine on any hiccup. Making it non-blocking took three more live freezes, each visible only on the running Mac.
Open Transport has two independent mode axes — synchronous/asynchronous and blocking/non-blocking — that interact in ways the documentation glosses over. Each test iteration meant cross-compiling on the host, copying over the bridge, linking on the Mac, running Rez, hot-swapping the binary and relaunching. A bug does not print a stack trace here. It freezes a 34-year-old operating system, and you start again.
The three regressions:
OTRcvConnectcalled withoutOTLook— in non-blocking mode you must drive completion fromOTLookand only receive onceT_CONNECTis actually pending.- A busy-spin — the poll loop used
GetNextEvent+SystemTask, which checks for events but never idles. The fix is the idiom every classic Mac app already knows:WaitNextEventwith a sleep value, which actually yields. - A freeze at 9 % CPU — the one that taught the real lesson. A line added “to be tidy” restored blocking mode after connecting, so the receive loop blocked instead of polling.
On a cooperative OS, a blocking call is a freeze with no CPU to show for it. Low CPU and unresponsive means blocked. High CPU and unresponsive means spinning. The two have opposite fixes.

What follows in that entry is rarer than the debugging: after three live freezes at the end of a long day, the decision was not to ship. Keep the proven build deployed, commit the corrected source, stop. Hot-swapping experimental firmware into a 1991 Mac while tired is how a fixable bug becomes a lost evening. It shipped the next session, deliberately, with a known-good binary one relaunch away.
Phase four: the crash that was hiding four crashes
For weeks, graphical apps built on the emulated Mac killed Basilisk II at launch. Every crash report showed the same headline — EXC_BAD_INSTRUCTION inside modern macOS window-management code. The conclusion wrote itself: an emulator bug. It even made it into an earlier article in this very corpus as an established fact.
It was wrong, and the trace had said so all along.
A stack trace runs backwards. Read bottom to top, the report describes Basilisk II already shutting down: a SIGSEGV arrived first, the handler called QuitEmulator, and the window fault happened during teardown. The real first fault was on thread 0, the 68k interpreter, stopped mid-instruction on a MOVE to an unmappable address.
One two-minute test settled weeks of theory: launch a known-good app four times — no crash. Launch the suspect hand-written assembly app — crashes every time. What predicted the failure was not “a GUI app launched” but which binary.
Rebuilding it surfaced four stacked bugs, and every one is a lesson from the MPW article made flesh:
| # | Bug | Why it was invisible |
|---|---|---|
| 1 | Unix LF line endings | The MPW assembler wants carriage returns; it read the file as one line and emitted an empty object file with only a mild warning |
| 2 | No entry point | Every earlier link used an invalid flag and failed; the binary had no real entry |
| 3 | No runtime startup | No %_MAIN / _DataInit / %A5Init — no A5 world, no globals, no jump table. A C program gets this for free |
| 4 | Wrong InitGraf argument | Passed the base of the QuickDraw globals instead of the address of thePort |
The line-ending bug is the one worth pinning to the wall. It is exactly the MacRoman-versus-UTF-8 hazard, and it failed silently — producing a valid-looking build with no code in it.
Phase five: reaching the menus
Compiling and running was solved. Driving a graphical application was not, and it took three attempts.
Synthetic events via PostEvent never reach modal tracking loops — MenuSelect, ModalDialog, Standard File — because those loops poll the hardware directly. A journaling driver solves that, but only inside the process that owns the loop, because MenuSelect uses the calling process’s menu list. A jGNE filter could read a foreign application’s menus but crashed the host when used to drive one.
The third mechanism worked: a global MenuSelect trap patch installed at boot from a system extension. A daemon-owned flag selects behaviour per call — disarmed, it passes through to the original routine untouched; armed, it writes the chosen menu and item directly into the caller’s Pascal return slot and returns immediately, without entering the tracking loop at all.
Two findings had to be earned along the way, and both are the kind of thing you only learn by getting them wrong:
A patch installed by a running application is process-local. Installing from the daemon worked — for the daemon’s own menus. The front application never passed through it. A patch belongs to the heap it lives in; only one installed from a system extension at boot lands in the system heap and applies everywhere. Compounding it: ToolServer snapshots and restores the trap dispatch table around each tool it runs, so a patch made by a command-line tool is silently reverted the moment the tool exits.
Verify a trap patch by scanning memory, not by reading the trap. After the first successful boot, the obvious check reported the patch absent. It was there. MenuSelect is heavily contended — the system patches it, later extensions patch it again, each chaining to the previous. Reading the trap vector returns only the topmost patch. Scanning the system heap for the patch’s own signature found it immediately, resident and intact.
Phase six: real silicon, and the bug only it could find
Everything so far ran against an emulator. The night it stopped pretending produced the single most valuable bug in the project.
The first physical candidate was a PowerBook Duo 230. An earlier THINK C demo, built over the bridge, ran on it — concentric Color QuickDraw rings on genuine 1992 hardware:

But the daemon would not launch: 4 MB soldered, roughly 3 MB free after the System, against a bridge that wants an 8–12 MB partition plus a TCP stack. The app fit; the service did not. The CPU was never the floor. Memory is.
The right machine was the SE/30 — but this one with 32 MB and an Asante Ethernet card. Enough headroom for the daemon, the System and a full TCP stack, and a real network card meaning the dial-out trick becomes an ordinary socket with none of the emulator’s NAT contortions.
The installer — built specifically to refuse a broken environment rather than fail silently later — ran on real hardware and mostly passed. System 7.5 ✓, Apple Events ✓, 32-bit addressing ✓, RAM ✓. One check failed:

TCP stack — none — need OT or MacTCP
On a machine with MacTCP plainly installed and configured — as the control panel shows plainly enough:

The installer was not lying. It was looking the wrong way. It detected MacTCP with Gestalt('mtcp') — and classic MacTCP does not register that selector. Only Open Transport’s compatibility shim does. Every emulator ever tested ran Open Transport, so the shim registered it and the check passed, every single time, for months.
A real machine running stock MacTCP has no shim. A correctly installed stack read as absent.
The fix was the probe the daemon had been using all along: open the .IPP driver. Classic MacTCP answers OpenDriver(".IPP") whether or not it bothers with Gestalt. That is also why the daemon connected fine while the installer balked — the two halves disagreed, and only silicon could referee.
Past the gate, the daemon read its preferences, opened .IPP, and dialled:

Phase seven: down to one wire
The last arc goes not up but down — to machines that cannot do Ethernet at all.
Target: the same SE/30, but reached over a plain serial cable. The Macintosh serial ports were built on the Zilog Z8530 SCC, capable of driving RS-422 at high rates (Wikipedia). The catch is that the same chip also carries AppleTalk — switching between a printer and the network is a matter of reprogramming the SCC’s control registers (MacTech).
Which produced fault one. The daemon installed, auto-launched, and thirty seconds later the SE/30 dropped a bomb — Error 11, a hardware exception. Emulated serial had never exercised a real SCC. With AppleTalk active, opening the port for raw I/O produces spurious interrupts. One toggle in the Chooser fixed it permanently.
Fault two was subtler. The daemon printed “Invalid command format” in a loop, and the host decided it was talking to a legacy peer. A byte-level probe showed every command answered with the same non-ASCII bytes — and consistent garbage is the signature of a baud mismatch, a UART sampling bits at the wrong boundaries. A rate sweep resolved it in seconds:
baud= 9600: ascii= 38% (garbage)
baud= 19200: ascii= 59% (garbage)
baud= 38400: ascii= 42% (garbage)
baud= 57600: ascii=100% STATUS:-1 STDOUT:0 STDERR:39 …
The daemon was running at its compiled default; the Control Panel’s BAUD= setting had never propagated. Matching the host to 57600 needed nothing on the SE/30 at all.
Then the payoff: protocol v2 negotiated, round-trips around 33 ms, and a directory listing of the boot volume served by the daemon’s own PBGetCatInfo walk — no ToolServer, no network. The genuine contents of a real SE/30: a German System 7.5, a FoxPro workstation, decades of accumulated folders.
And then the image this whole arc was for — the machine’s own 512×342 screen, captured over the same serial wire, showing the daemon’s console with the LISTDIR commands and the SCREENSHOT verb landing live. It caught itself being read:

The control panel from the same session records both the success and an honest defect — the window runs off the bottom of a 512×342 display, and BAUD reads 9600 while the daemon actually runs at 57600:

The residue: P and ING
Even working, the serial link accumulated errors — err=10 lasterr=badreq. Correlating both sides of the wire found the mechanism exactly. The guest console shows it in four characters:
PING
P
Invalid command format
ING
Invalid command format
One PING arrived as two fragments. The length-framed protocol was designed for TCP — a reliable, ordered stream. Raw RS-422 has no hardware flow control, so timing can shift a frame boundary by a byte. The daemon rejects both halves, its reply misaligns, the host times out and drains stale bytes until the stream re-aligns.
Notably, nothing was corrupted: one WRITEFILE reported a four-minute timeout, but the file on disk was exactly the expected 6580 bytes. The write committed; only the acknowledgement was lost.
What the numbers say
The same machine, the same daemon, only the transport changed:
| Metric | Serial (RS-422 @ 57600) | MacTCP (Ethernet) |
|---|---|---|
| Screenshot (21,888-byte pixmap) | ~4.5 s | ~0.7 s |
| Bulk transfer | ~4.9 KB/s | ~218 KB/s sustained |
| Command latency | 30–85 ms | ~33 ms |
| Errors | framing desync, stale-byte drains | 0 |
| Setup | AppleTalk off, baud match, cable | plug into the LAN |
| Reach | any Mac with a serial port | needs an Ethernet card |
The interesting part is why the fast number is not faster. The Asante card is 10BASE-T — about 1.25 MB/s of raw Ethernet — but a 16 MHz 68030 cannot saturate that. Classic MacTCP copies every byte through its own buffers under cooperative scheduling. The CPU is the ceiling, not the cable: roughly 18 % of the wire, consistent across file sizes, exactly where a 68030 TCP stack has always lived.
Serial’s 4.9 KB/s, by contrast, was the wire — 84 % utilisation of 57600 baud, with nothing left to give. Serial had no headroom. MacTCP’s headroom is the processor.
And the screenshot number misleads in the other direction: at ~31 KB/s end-to-end it looks slower than bulk transfer, because it is capture-bound, not transfer-bound. Most of that 0.7 s is the daemon reading the 1-bit screen with CopyBits; the pixmap itself crosses the wire in under 100 ms.
The reliable link made one more thing practical: driving the machine’s own Finder. A click on the desktop brings it forward — the full German menu bar, Ablage · Bearbeiten · Inhalt · Etikett · Spezial:

Then select the disk icon and send ⌘-I. The keyboard path is the reliable one: the front application dispatches it through MenuKey, where a synthetic click could never drive the menu’s mouse-tracking loop.

Where it stands
At v0.7.0: forty-five merged pull requests, an empty issue tracker, and not a single TODO marker in the codebase. The guest side is a faceless C daemon with selectable transports behind a common seam — Open Transport, MacTCP, serial — chosen by a preferences key, plus a control panel, a watchdog owning the daemon’s lifecycle from Startup Items, a preflighting installer, and trap-patching extensions in hand-written 68k assembly. The host side is a dependency-free Python relay exposing twenty MCP tools.
The assembly is not stylistic preference. A standalone INIT or code resource runs with no A5 world, and MPW C cannot emit a working one: the address of a C callback becomes an A5-relative jump-table reference the linker cannot fold into a bare resource. Four components had to be written in assembly because C structurally cannot express them.
The honest backlog is equally on the record: a resync marker and per-frame checksum for the serial framing, reply-ack hardening so a committed write never reports a false timeout, a compact-Mac window layout for the 512×342 screen, and the BAUD= preference that still does not propagate.
What the whole thing demonstrates
Three things, none of which is “AI is impressive.”
The design decision was made in 1986, for other reasons. MPW is a set of individually invocable tools that take text and return text, driven by a shell, with ToolServer as a headless interpreter reachable by Apple Events from any program. Apple built a remote-controllable build server before the word headless was used for computers. Nobody was thinking about language models. They were thinking about scriptable builds.
Latent capability is not capability. The door was open for forty years and nobody walked through it. Between the two states lie six months, four freezes, four stacked build bugs, a Gestalt selector that only real hardware could disprove, and a PING that arrived as P and ING.
Emulators are honest but incomplete. Every one of the project’s hardest-won bugs was invisible under emulation — the SCC interrupt conflict, the MacTCP detection failure, the baud mismatch, the frame desync. Not because the emulator was bad, but because an emulator reproduces what it was written to reproduce. Real silicon has no such courtesy.
The SE/30 is a machine that shipped with 32-bit dirty ROMs — a monument, in hardware, to eight bits borrowed from an address because in 1984 memory was too precious to waste. It now answers an AI agent over a wire, showing its own screen, running its own FoxPro, thirty-seven years after Jean-Louis Gassée called it the ultimate compact Macintosh.
The bridge holds because both banks were taken seriously: a 68030 that cannot be interrupted, and a scheduler that must be handed back voluntarily, every single time.
Long live 24 bits.
Quellen
- AppleBridge project record, 61 entries: https://pit.390er.de/applebridge/
- Anatomy of a Freeze — MACNAT return path: https://pit.390er.de/applebridge/anatomy-of-a-freeze-macnat-return-path/
- Three Freezes and a Verdict — async Open Transport: https://pit.390er.de/applebridge/three-freezes-async-open-transport/
- The Last Frame Is Not the Cause — a 1991 build bug: https://pit.390er.de/applebridge/the-last-frame-is-not-the-cause-a-self-masking-crash-a-misdiagnosis-and-a-1991-build-bug/
- Driving a Foreign Application’s Menus — global MenuSelect trap patch: https://pit.390er.de/applebridge/driving-a-foreign-application-s-menus-a-global-menuselect-trap-patch-installed-at-boot/
- AppleBridge on Real Silicon — SE/30 over MacTCP: https://pit.390er.de/applebridge/applebridge-real-silicon-macintosh-se30-mactcp-ethernet/
- Driving a Macintosh SE/30 over a Serial Cable: https://pit.390er.de/applebridge/macintosh-se30-over-serial/
- Serial Frame Desync — root-cause note: https://pit.390er.de/applebridge/serial-frame-desync-root-cause/
- Two Transports, One SE/30 — serial vs MacTCP: https://pit.390er.de/applebridge/se30-serial-vs-mactcp-performance/
- AppleBridge at v0.7.0 — roadmap analysis: https://pit.390er.de/applebridge/applebridge-at-v0-7-0-a-roadmap-analysis-and-the-improvement-space-ahead/
- Macintosh SE/30 — Wikipedia: https://en.wikipedia.org/wiki/Macintosh_SE/30
- Macintosh SE/30 (32-bit dirty ROMs) — Apple Wiki: https://apple.fandom.com/wiki/Macintosh_SE/30
- Basilisk II — Wikipedia: https://en.wikipedia.org/wiki/Basilisk_II
- Zilog SCC — Wikipedia: https://en.wikipedia.org/wiki/Zilog_SCC
- AppleTalk Connections — MacTech Magazine: http://preserve.mactech.com/articles/mactech/Vol.02/02.04/ATalkConnections/index.html
- Long live 24 Bits! (companion article): https://pit.390er.de/68000/long-live-24-bits-vom-68000-zur-lauffaehigen-anwendung-mit-mpw/
