I turned a 2010 slider phone into a remote for my coding agents
An LG Ally, its stock 2010 kernel, a Rust UI that draws every pixel itself, and a lot of reverse engineering I mostly watched.
This is an LG Ally. Verizon sold it in 2010 with Android 2.2, a 600 MHz single-core ARM chip, 173 MB of usable RAM and a slide-out keyboard. Its cell network has since been switched off. It now has exactly one job: showing me what my coding agents are doing and letting me boss them around from the couch.
It doesn't run Android anymore. It runs what I've been calling UnityOS: the original kernel boots into a tiny Linux off the SD card, and a single Rust program draws every pixel of the interface. I can see every agent across my machines, read their conversations, answer their permission prompts, dictate replies by pressing the Call button, attach photos from the 3 MP camera, and launch new agents. All of this works at home or anywhere else.
The honest part is that the agents also built it. They decompiled LG's drivers, wrote the OS and measured everything. My contribution was mostly approving things and holding the phone. I also had to press the Power button by hand when an early test forgot to time out. Since then, every test has to have a timeout and a way back in over the network.
So naturally it got a launch keynote.
The rest of this post is the technical notes, for anyone who wants the ioctl numbers. All of it is also a 15-page PDF at the bottom.






The numbers up front
| Measure | Result |
|---|---|
| Kernel start to first UI frame | 3.3 s |
| RAM in use, whole system | 9.5 MB of 173 MB |
| UI process, idle | 4.5–5.3 MB resident, 0.6 % CPU |
| Held awake per background check | 0.84 s (was 20 s) |
| End of speech to final text, home network | 0.58 s |
| Root filesystem | about 25 MB (Android's /system was 128.5 MB) |
| Battery life | unmeasured, which the keynote calls "infinite" |
Everything was measured on the phone with USB attached unless it says otherwise. Battery-only suspend hasn't been tested yet, so nothing below is a battery-life claim.
The phone
| Part | Detail |
|---|---|
| SoC | Qualcomm MSM7627: an ARM11 core for Linux, an ARM9 running the modem, a QDSP5 audio/video DSP, and an MDP display processor |
| CPU | ARM1136JF-S, ARMv6 + VFPv2, no NEON, 122.88–600 MHz |
| Memory | 256 MiB in the package, 173,848 kB visible to Linux |
| Display | 3.2" 480 × 800, RGB565 framebuffer, two pages for double buffering |
| Input | capacitive multitouch (protocol A), four-row QWERTY + d-pad, a slide switch, Call/End/Home/Menu keys |
| Radios | BCM4329 Wi-Fi b/g/n + Bluetooth 2.1; a CDMA modem with no network left to talk to |
| Camera | MT9T111, 2048 × 1536, with an LED flash on an AAT1270 driver |
| Storage | 512 MiB NAND (left alone apart from boot) and a 32 GB microSD card that now holds the OS |

The 2010 kernel sets most of the rules. It only has ext2/ext3, has no devtmpfs, uses Android-style wakelocks, and has no thread-pointer register support for newer ARM userlands. It's also too old for the modern Bluetooth daemon.
How it boots
The stock kernel stays, because it's the only one with drivers for this board. Next to it in the boot partition sits a 30-line busybox script. It mounts the SD card read-only, checks for an init, and runs switch_root. If anything goes wrong, it sits in a loop running adbd over USB, so a broken SD card can always be fixed. The boot image is packed by a pure-Python script, flashed from the stock recovery, and verified by MD5. Nothing else on the internal flash was touched. The stock recovery stays as the way back.
| Step | Before | After |
|---|---|---|
rc.sysinit |
0.76 s | 0.22 s |
| Boot splash on screen | — | 1.83 s |
| UI process to its first frame | 600–718 ms | 191–231 ms |
| Kernel start to first frame | 4.28 s | 3.3 s |
The reverse engineering
The kernel had drivers. What it didn't have was any explanation of how to use them, which lived in LG's closed Android libraries. A coding agent drove Ghidra headless through REA, a reverse-engineering CLI. It decompiled the stock binaries, then replayed their exact call sequences from plain Linux. A few of my favourite findings:
| Thing | What was going on |
|---|---|
| Userland | Alpine segfaulted on every binary. It reads the thread pointer from a CP15 register this ARMv6 kernel never sets, so we switched to Buildroot for exactly arm1136jf-s. |
| Audio | The kernel's ALSA path oopses the kernel. LG's libaudio.so showed the DSP refuses AUDIO_START unless both buffers are prefilled first. |
| Flashlight | The flash chip only reacts to brightness 255 (flash) or 127 (torch, as seven pulses on a pin). Every other value is silently ignored. The UI had been sending 100. |
| Camera crash | LG's camera library crashed on the first frame. It was reading a Bayer tuning pointer that is NULL for this YUV sensor. The fix clears one flag byte about 130 ms before the first frame. |
| Camera wedge | On close, LG's kernel driver waits 200 ms for an acknowledgement from a thread that's already dead, then gates the DSP clocks mid-stop. The DSP never recovers until a reboot, so the fix makes that state impossible: 35 of 35 stress cycles came out clean. |
| Modem | The Call Manager's ONC RPC calls, recovered from libcm, put the modem into low-power mode at boot. The log says was 0 pwroff: the modem had never been on. It ships anyway, as insurance. |
| Bluetooth | The kernel never sets the HCI event mask, so pairing failed. LG's hci_conn_info struct is 28 bytes, not 16, and the standard layout corrupted the heap. |
| Copies | The libc memcpy into the framebuffer ran at 36 MB/s. An 8-register ldmia/stmia loop runs at about 166 MB/s. |
The camera ended up as a "bionic island". Our own C program is linked against the stock Android libc and uses LG's own dynamic linker as its ELF interpreter, so LG's camera libraries run unmodified. Live preview comes back as raw RGB565 frames on a pipe.
The OS
Buildroot builds a fully static musl userland for the exact core, and busybox init is the only service manager. Compared with Android's 37 services and the whole Java framework, it runs eight programs:
| Process | Job |
|---|---|
allyui |
The whole interface, the hub client, audio, power policy. 3.6 MB stripped. |
ally-sensors |
Slide, headset, dock, proximity and battery into a JSON file. It sleeps in poll(), has no timer, and uses 56 kB. |
ally-modem |
Runs once at boot and puts the modem in low-power mode. |
wpa_supplicant |
Wi-Fi with an exponential autoscan; DHCP hooks report network changes. |
dropbear + tunnel |
Key-only SSH, plus a reverse tunnel to a public relay for remote access. |
ally-camera, ally-bt |
On demand only. Bluetooth is off by default. |
The UI
allyui is one static Rust binary: no toolkit, no async runtime, no GPU. The UI thread sleeps in poll(), and every pixel is computed on the CPU into an RGB565 canvas.
Landscape mode is just the portrait panel turned sideways. Rotating 768,000 bytes on the CPU every frame was the biggest single cost, so the rotation moved to the display processor's blit engine, using the 2010 kernel's own 88-byte request format:
| Full-frame present | 0° | 90° | 180° | 270° |
|---|---|---|---|---|
| CPU copy | 6.01 ms | 14.76 ms | 14.64 ms | 15.11 ms |
| MDP blit | 9.11 ms | 8.97 ms | 8.91 ms | 8.96 ms |
Portrait and small updates still use the CPU, because a straight copy wins there. The CPU's minimum clock had also been stuck at 600 MHz for 82.5 % of the time. Fixing that, plus a short boost while a finger is down, took touch-to-scroll latency from 17.26 ms to 4.53 ms.
The third round went after composition. Dialogs and panels now keep a cached, dimmed copy of whatever is behind them, and the alpha blend handles two pixels per 32-bit word:
| Workload (phone) | Render before → after | CPU before → after |
|---|---|---|
| Landscape Wi-Fi panel | 73.42 → 8.19 ms | 10.12 → 4.26 % |
| Portrait Rename dialog | 66.16 → 19.31 ms | 9.57 → 2.79 % |
| Landscape End dialog | 69.58 → 15.93 ms | 9.58 → 3.00 % |
| Portrait fleet fling | 6.60 → 5.08 ms | 31.61 → 22.48 % |
Memory went the same way. Fonts now load lazily, Markdown layout picks up from a checkpoint instead of redoing the whole conversation on every streamed token, and image decodes are capped:
| Measure | Before | After |
|---|---|---|
| UI resident, idle | 10.7 MB | 4.5–5.3 MB |
| After opening a 250 KB conversation | 14.3 MB | 5.3 MB |
| Worst frame opening that conversation | 1,336 ms | 92 ms |
| Frame while an answer streams in | 190–260 ms | 22–28 ms |
| Thumbnail of a 3264 × 2448 photo | 24 MB | 1.3 MB |
Screens
These are native-resolution captures from the simulator, which is the same code built for a laptop. They run on made-up demo data, so nothing here is a real account.









Talking to the agents
The phone is an ordinary external app to my agent hub. It pairs once with a device code, holds a scoped token and uses the normal API: fleet list, transcripts over server-sent events, send or steer, launch, and answer. Every action carries a stable command id, so a retry after a dropped connection can never send twice. At home it talks to the hub directly; anywhere else it goes through a public relay. It switches silently and re-probes home whenever the Wi-Fi network changes.
Polling got a lot lazier in round 3:
| Background polling (model) | Requests/h before | after |
|---|---|---|
| Awake, active (5 s) | 1,440 | 780 |
| Awake, idle (15 s) | 480 | 300 |
| Asleep, quiet fleet | 60 | 10 |
Agents driving the phone
The part that made all of this possible to build is a second control plane. From any of my machines, an agent can grab the phone over pinned SSH, lock it, tap, swipe, type, take screenshots, record video, and deploy a new UI build.
| What | How |
|---|---|
| One controller at a time | A kernel flock on the phone. A dead controller is noticed within 12 s, and a hard watchdog fires at 290 s. |
| Always handed back | Disconnects and signals all restore the phone to awake, on the fleet screen, with the slide state it found. |
| Safe deploys | MD5 is checked before the swap. The new process has to log its own first frame or it gets rolled back, which it did automatically in both failure tests. |
| Recording | Only changed pixels are sent over a second SSH stream: 5.0 fps, an unchanged frame costs 38 bytes. |
| Through the relay | Uploads run at 29.4 KiB/s, so big deploys stay on home Wi-Fi. |
Dictation
A 600 MHz ARM11 can't run speech recognition, so the phone records 16 kHz audio and a speech model on a laptop GPU transcribes it. The phone streams chunks and gets back committed text plus a tentative tail, which it keeps replacing as the model gets more confident.
| Measure | Before | After |
|---|---|---|
| Call key to microphone open | 622–641 ms | 28–30 ms |
| End of speech to final text, home | 0.917 s | 0.581 s |
| Same, through a slow relay (300 ms RTT) | 1.990 s | 1.616 s |
| Same, on a bad link (800 ms RTT) | 17.088 s | 4.293 s |
Power, honestly
| Measure | Result |
|---|---|
| Wakelock held per background check | 20.000 s → 0.844 s |
| Forced awake per hour, quiet fleet | ~4.2 s (model) |
| CPU at its lowest clock while idle | 98.2–98.4 % |
| UI CPU with the screen off | 0 ticks in a minute |
| Services other than the UI | 0 ticks |
The software isn't what costs power anymore. What I can't tell you yet is the battery life. The battery only reports whole percent, there's no current sensor, and every test so far had USB plugged in. USB holds a wakelock, so the phone has never actually reached full suspend. A long unplugged test is next. LG rated the phone at 500 hours of standby, about 3 mA, so that's the number to beat.
What isn't done
- Battery-only suspend testing, as above.
- A real Bluetooth keyboard, tested end to end. Pairing works with a laptop.
- A cold power-on from the charger. Warm reboots with the cable in are tested.
- GPS, which lives inside the modem behind more RPC calls.
- A guaranteed 60 fps, which would mean drawing straight into the framebuffer in the panel's own orientation.
It went from a phone in a drawer to this in two days and about 130 commits. Each round was planned from research the agents measured themselves, and it ended in an independent review that found six real bugs, all fixed. Not bad for something with less RAM than a browser tab.