kevencrick
Personal AI Slop

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.

October 14, 2026 · 14 min

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 UnityOS launch keynote. The numbers are real. The battery-life claim is not.

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 LG Ally upright with the keyboard shut, showing the fleet screen in portrait
Keyboard shut is portrait, and sliding it shut also puts the phone to sleep. Photo of a real Ally (iFixit, CC BY-NC-SA 3.0) with the screen composited in.

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

Boot chain from the bootloader through the stage-1 initramfs to busybox init and the UI, with rescue and charge branches
The stock kernel plus a 30-line stage-1 script in the boot partition. If anything fails it falls back to a rescue shell over USB.

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.

Camera stack diagram, from the musl front end to the bionic island running LG libraries to the kernel driver and DSP
A musl front end, LG's libraries under LG's own linker, and the DSP doing the JPEGs.

The OS

Architecture diagram of processes, kernel interfaces and hardware on the phone, and the hub, speech service, relay and controllers off it
Everything on the phone, and the four things it talks to.

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.

Frame pipeline from state change through a visibility check, drawing, an MDP blit and a page flip
One frame. Changes you can't see draw nothing; the display processor rotates landscape.

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.

Fleet screen in landscape with agent cards on the left and the selected agent's detail on the right
The fleet. Agents that need me sort first, and rows never jump around as their status changes.
A conversation with a monospace table, a task list, a quote and a link
A conversation: Markdown, tables, task lists, tool runs and plans, streamed live.
New agent form with provider, machine, effort, label, folder and prompt fields
Launching an agent from the keyboard, with every option from the hub's live catalog.

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.

Control plane diagram from the ally CLI over LAN or relay to the phone's SSH server, control helper, input injection and frame stream
The ally CLI. One controller at a time, and the phone is always handed back awake.
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.

Dictation pipeline from the Call key through the microphone and sender thread to the speech service and the composer
Chunks adapt from 1 to 3 seconds depending on how slow the link is.
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.