DEV Community

Streaming a desktop agent to your phone with zero npm dependencies

A weekend project that streams a desktop agent to your phone - with zero npm dependencies. 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.

One process, three responsibilities

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.

Decision 1: one WebSocket for all realtime events

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.

Decision 2: the question channel is NOT the approval channel

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": ["..."]
        }
      ]
    }
  }
}

Decision 3: tool calls become cards keyed by callId

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.

The result

  • ~4000 lines, zero dependencies
  • Single-file frontend (relay.html)
  • Streaming typewriter, tool cards, approval/answer cards, model switching, terminal, file browser
  • 4G/5G via Tailscale, no public IP

Why this matters

"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): https://github.com/2903077918-lgtm/DeepSeek-phone-harness

Questions about the architecture? Happy to go deeper in the comments.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.