One WebSocket, one Node process, one HTML file. Here's the architecture behind DeepSeek Phone Harness, and the three decisions that made it work.
Last weekend I built DeepSeek Phone Harness β a mobile remote for DeepSeek Harness. The constraint I set myself: zero npm dependencies. Not because I'm a purist, but because a tool like this should be "clone and run," not "install 47 packages and hope."
Here's the architecture that made it work.
Phone browser (relay.html)
β HTTPS / Tailscale
βΌ
Agent (Node, :8788) ββ http server + static page + WS relay
β 127.0.0.1:3080
βΌ
DeepSeek Harness gateway (dsh web)
The Agent is a translator, not a replacement: it speaks REST to the phone and RPC to DSH.
DSH exposes events.mux
β a WebSocket stream carrying approvals, questions, and session events in one pipe. Instead of opening one connection per concern, I keep one persistent connection and route frames locally:
| Frame | Goes to |
|---|---|
approval/requested |
|
| pending table β phone approval cards | |
question/requested |
|
| pending table β phone answer cards | |
session/event |
|
| ring buffer (200/session) β phone stream polling |
The phone polls /api/events?afterSeq=N
β each poll returns only new bytes, which keeps 4G streaming smooth.
The single most important bug I fixed: DSH agents call ask_user_question
through a separate channel from approvals. If you only listen for approval/requested
, the moment the agent asks something the task deadlocks forever β it's waiting for a human answer that the phone never shows.
The answer protocol had to match the Web GUI exactly:
{ "ok": true, "value": { "sessionId": "...", "answer": { "answers": [{ "id": "q1", "selected": ["..."] }] } } }
Every tool/call
event creates a tool card (name, icon, status, file path extracted from arguments). Every tool/result
pairs back by callId
and flips the card to done/failed. Tapping expands the full arguments and result.
One trap: DSH's tool/result
content is double-nested (content[].content[].text
) β my first version read one level too shallow and results came back empty. Lesson: capture a real payload before trusting any schema.
relay.html
)"Phone remote for a desktop agent" sounds like it needs a platform team. It doesn't. The barrier to entry for this whole category just collapsed β anyone with Node 22+ can build or extend one in a weekend.
The code is open (MIT):
Questions about the architecture? Happy to go deeper in the comments.