I wanted a simple thing: text myself on my phone, have it kick off a task on my desktop. speech-to-text, autocorrect, swipe typing. the Messages compose UI is genuinely the best input interface on a phone. I wanted to use it as a command line.
the idea was straightforward. I already have TaskPilot, which spawns autonomous Claude Code sessions. I already have a Tailscale mesh connecting my Pixel 7 Pro to my Linux workstation. just needed the bridge: SMS in, webhook out, task spawned.
the RCS wall
first attempt was an SMS watcher script running in Termux. poll termux-sms-list every 10 seconds, filter for self-texts, POST to a channel. simple enough.
except when I texted myself, nothing happened.
turns out Google Messages on Pixel handles self-texts via RCS, not SMS. and RCS messages don't hit the SMS content provider that termux-sms-list reads from. they're stored entirely within Google Messages' own RCS layer. invisible to Termux.
checked everything. termux-sms-list with different type filters. the notification shade (self-texts don't generate notifications either). even tried logcat, which Android sandboxes to your own app's logs without root. total dead end.
Google removed the per-message "send as SMS" option from Messages too. it's RCS globally on or off. turning off RCS for everyone just to make self-texts work wasn't an option.
the notification angle
pivoted to termux-notification-list. if I can read notifications, maybe I can catch messages that way. granted notification access to Termux:API, confirmed it works for incoming messages from other people.
but self-texts? no notification generated. Android doesn't notify you about messages you sent to yourself. makes sense from a UX perspective. terrible for automation.
at this point I'd burned through every Termux API: SMS list, notification list, content providers, logcat. RCS self-texts on a Pixel 7 are a black hole. no way to observe them without root or a paid automation app like Tasker.
dropping the middleman
I stepped back. the real requirement wasn't "read my self-texts." it was "use a good input interface on my phone to send tasks to my desktop." Claude Code on the phone wasn't even necessary for this. I just needed a lightweight Android app that catches notifications and posts them to a webhook.
so I built one. nov-relay. Kotlin, NotificationListenerService, about 200 lines of actual logic.
the app does three things:
- watches for Google Messages notifications and relays them as JSON to a configurable webhook URL
- has a Quick Send input with full keyboard and speech-to-text for sending messages directly (bypassing the self-text problem entirely)
- posts over HTTPS via Tailscale serve, so the traffic stays encrypted and private to my tailnet
building without Android Studio
didn't want to install Android Studio (10GB+) for a 200 line app. set up the Android SDK command-line tools instead. about 1.5GB total: cmdline-tools, platform 34, build-tools.
sdkmanager "platforms;android-34" "build-tools;34.0.0" "platform-tools"
./gradlew assembleDebug
the APK builds in under 10 seconds. sideload via the static site hosting I already have set up. download the APK on the phone, tap install, grant notification access, done.
ran into one gotcha: Android blocks cleartext HTTP by default. the webhook target was a Tailscale IP on port 8789. instead of adding usesCleartextTraffic, I set up tailscale serve to front port 8789 with automatic HTTPS:
tailscale serve --bg --https 8443 8789
now the webhook URL is https://local-yocal.tail095a85.ts.net:8443. proper TLS, no cleartext exceptions needed.
the desktop side
on the desktop, a channel MCP server listens on port 8789. it's a Node.js script that accepts HTTP POSTs and pushes them into a Claude Code session as channel messages. runs in a tmux session so it persists.
tmux new-session -d -s desktop-channel \
"claude --dangerously-skip-permissions \
--dangerously-load-development-channels server:desktop-channel \
--name desktop-channel"
when a message arrives from nov-relay, Claude sees it as a channel notification with the full JSON payload: sender, body, whether it's a self-message, timestamp. it decides what to do. incoming text from someone? acknowledge it. self-sent task via Quick Send? spawn a TaskPilot session.
the full pipeline
phone (nov-relay app)
|
| HTTPS over Tailscale
v
desktop (tailscale serve :8443 -> :8789)
|
| channel MCP
v
Claude Code session
|
| spawner_cli.py
v
TaskPilot (autonomous tmux session)
I type "research the best hiking trails near portland" into Quick Send. it hits the desktop in under a second. Claude evaluates it, spawns a TaskPilot task, and the task runs autonomously. if it needs input, it can notify me back through the same Tailscale connection.
incoming messages from other people get relayed too. the notification listener catches anything from Google Messages. I can see texts come in and have Claude triage them if I want.
what I learned
the original plan (SMS watcher in Termux) was the wrong abstraction. I was trying to read messages from a database that RCS doesn't write to. the NotificationListenerService approach works because it operates at the Android system level, above the SMS/RCS split.
but even that has limits. self-texts don't generate notifications. the real fix was adding the Quick Send input directly in the app. same keyboard, same speech-to-text, just posts straight to the webhook instead of going through Messages first.
sometimes the best bridge is the one that skips the gap entirely.