WorkFromPhone
Code on a Linux machine from your phone
- Year
- 2026
- Kind
- Systems
- Status
- In use
A Flutter app and FastAPI backend for working on a Linux machine from a phone: terminals, files, git, dev server previews and an AI coding agent. On Android the backend can run on the phone itself.
WorkFromPhone pairs a Flutter app with a FastAPI backend that runs on the Linux machine. A setup wizard installs the backend over SSH and connects through an SSH port forward or a Cloudflare tunnel, with a bearer token on every request. The app gets PTY terminals over WebSockets, a file editor, git staging and diffs, a system monitor, previews of local dev servers, and an agent loop that edits and runs code through nine tools. No-PC mode runs the same backend in a Debian container under proot on Android.
The problem
From a phone, a Linux machine is usually a bare SSH session: no file tree, no diffs, no view of the dev server you just started. WorkFromPhone pairs a Flutter app with a FastAPI backend on the machine. Together they give the phone a terminal, a file editor, git, a system monitor, a preview of local web servers and an AI coding agent. On Android, the machine can be the phone itself.
How it works
- 01
Install and connect over SSH
A setup wizard logs in over SSH, asks you to confirm the host key and checks the machine with uname. It installs the matching x86_64 or aarch64 build after verifying its SHA-256, as a systemd user service listening only on 127.0.0.1, with a generated access token. The app reaches it through an SSH port forward or a Cloudflare named tunnel, so no port faces the internet.
- 02
Run a real shell in a PTY
Each terminal tab opens a WebSocket. The backend creates a pseudo-terminal, the device a shell expects to talk to, and starts the shell as the session leader that owns it, so job control and Ctrl-C work as they do over SSH. Keystrokes and window resizes arrive as JSON frames. The app adds the keys phone keyboards lack: ESC, TAB, CTRL and the arrows.
- 03
Stream output as it arrives
Output is read in chunks of up to 4 KB and sent straight back, through an incremental UTF-8 decoder so a character split across two reads isn't garbled. Reads wait on the event loop, not in a thread, because a thread blocked in os.read isn't woken when the PTY closes.
- 04
Hand the project to an agent
The chat tab runs an agent loop on the backend against any OpenAI-compatible provider, streamed to the app over Server-Sent Events. Each step streams the model's reply, runs the tools it calls and feeds back the results, up to 50 steps by default. The nine tools include run_terminal_command, edit_file by exact substring and register_preview, which opens a running dev server in the app's browser.
- 05
Or run the backend on the phone
No-PC mode runs the same backend in a minimal Debian filesystem under proot, a user-space chroot that needs no root access. The 150 to 300 MB image is downloaded on first run and checked against a SHA-256 manifest instead of being bundled into the APK. The app then talks to 127.0.0.1:8000 as if it were a remote server.
Results
- 84
- backend test functions (pytest), run in CI
- 83
- Flutter unit and widget tests, run in CI
- 2
- Linux architectures shipped: x86_64 and aarch64
Decisions
Loopback by default, a token always
The backend won't start on a non-loopback interface without an access token, and checks it in constant time. On the phone the token is required even on 127.0.0.1, because any app on the device can reach localhost.
No automatic reconnect for terminals
A new WebSocket means a new shell. Reconnecting silently would hide that the old session is gone, so the app shows the disconnect and offers Retry.
Read tool calls from plain text too
Open-weight models such as DeepSeek R1 and Qwen 2.5 Coder often write tool calls as text. The agent falls back to parsing <function=…> tags, <tool_call> blocks and Markdown code blocks, so those models can drive it too.
What I learned
- Android was the hardest target. proot needs libtalloc.so.2, but Android only installs libraries whose names end in .so, and app processes ignore LD_LIBRARY_PATH. So the build rewrites that name inside the proot binary, in place, instead of using patchelf.