Engineering · Casting series, part 1 of 4

Fundamentals and Launching Your Own Receiver

Google Cast is not a video protocol — it's an app launcher with a message bus. How casting actually works, registering a custom receiver, and the unpublished-app trap that stalls most teams on day one.

This is part 1 of a four-part series drawn from shipping Chromecast casting in Remotype, the app that turns your phone into a keyboard, trackpad, and remote for every computer you own.

1.1 The mental model most tutorials get wrong

Google Cast is not a video protocol. It is an app launcher with a message bus.

When you "cast" something, three things happen:

  1. A sender (your phone or, in our case, a desktop companion app) opens a TLS connection to the Chromecast on port 8009 and speaks CASTv2 — a lightweight protobuf-framed protocol.
  2. The sender asks the Chromecast to launch a receiver app — a web page, identified by an 8-character App ID, that the device fetches from your server and runs in its built-in browser.
  3. Sender and receiver then exchange arbitrary JSON over namespaces — named channels multiplexed on that same connection.

That's it. Google gives you a browser on the TV and a message pipe to it. Everything else — what media flows, over which transport, at what quality — is your design. This is enormously freeing once you internalize it: the receiver is your web page, so if the page can do it (WebRTC, WebSockets, MSE, canvas), your cast can do it.

For screen mirroring with sub-second latency, the design that works is:

┌──────────────┐  CASTv2 (launch + JSON signaling)  ┌─────────────────┐
│  Desktop app │ ─────────────────────────────────► │   Chromecast    │
│  (sender +   │                                    │  runs YOUR page │
│   encoder)   │ ◄───────── WebRTC (H.264/Opus) ──► │  <video> tag    │
└──────────────┘         direct, on the LAN         └─────────────────┘

The Cast connection is only the doorbell and the handshake channel. The pixels travel over a WebRTC peer connection established directly between the computer and the TV — which is what makes sub-second latency possible where HLS (the usual casting shortcut) gives you ten seconds of buffering.

1.2 Registering your receiver: every step, every trap

You need a Google Cast Developer Console account (cast.google.com/publish, one-time $5 fee). Two warnings before you begin:

⚠️ The account email can never be changed. Register under an organization account you'll keep forever, not a personal one.

⚠️ Use your final receiver URL. If your page later moves — even to a URL that 301-redirects — update the console. We shipped with custavia.com/remotype/cast-receiver/ which later became a redirect to remotype.custavia.com/cast-receiver/; Cast devices follow redirects, but you don't want to depend on it.

The steps:

  1. Add New Application → Custom Receiver. Enter a name and your HTTPS receiver URL. You get your App ID (ours is 93EC8A58). HTTP is tolerated during development; published receivers must be HTTPS.
  2. Register your test devices. Console → Devices → Add New Device, entering each Chromecast's serial number (Google Home app → device → settings → Device information). Then wait — genuinely — 15 minutes, and then power-cycle the Chromecast. Skipping the reboot is the #1 reason for the error below.
  3. When it works, fill in the listing details and click Publish.

1.3 The unpublished-app trap: `LAUNCH_ERROR: NOT_FOUND`

Here's the failure that cost us the most calendar time, and the diagnostic that finally cracked it. When your sender asks the device to launch an App ID it can't resolve, the receiver namespace answers:

{"type":"LAUNCH_ERROR","reason":"NOT_FOUND","requestId":1}

NOT_FOUND does not mean your URL is wrong or your page is broken. It means Google's Cast infrastructure won't resolve this App ID for this device. That happens when the app is unpublished and the device isn't registered as a test device (or wasn't rebooted after registration).

Three facts that took us too long to establish:

  • The Chromecast does not need to be signed into the same Google account as your developer console. Device authorization is by serial number in the console, not account linkage.
  • "Unlisted" is not the blocker — "unpublished" is. These are different switches. Listed controls whether your receiver appears in Google's public app directory (cosmetic; requires a 512×512 icon). Published controls whether it launches on non-test devices. Published + Unlisted is the correct end state for a companion receiver: it works on every Cast device worldwide without being advertised anywhere.
  • Build yourself a probe tool on day one. Ours is a one-file CASTv2 client with two flags that answered more questions than any documentation:
$ cast-helper --castprobe 192.168.86.237 --appid 93EC8A58
[probe] connected, launching 93EC8A58
[cast] LAUNCH_ERROR from sink: {"reason":"NOT_FOUND",...}   ← not published/registered

$ cast-helper --caststatus 192.168.86.237
running app: 93EC8A58(Remotype)                              ← ground truth of what's on screen

The --caststatus probe deserves special mention: it tells you which app the TV is actually running, which is how you distinguish "my WebRTC receiver is up" from "the fallback path launched Google's Default Media Receiver instead" — two states that can look identical from the couch.

1.4 Launching over CASTv2

With the receiver registered, launching it is four messages. Using Go and go-chromecast's connection primitive (any CASTv2 library works — but read Part 2 before choosing, because one of its defaults will bite you):

const (
    nsConnection = "urn:x-cast:com.google.cast.tp.connection"
    nsReceiver   = "urn:x-cast:com.google.cast.receiver"
    ourNamespace = "urn:x-cast:com.custavia.remotype.cast" // yours: any urn:x-cast:* string
)

// 1. TCP+TLS to the device, port 8009, then a virtual CONNECT to the platform.
conn.Start(deviceIP, 8009)
send(dstReceiver, nsConnection, `{"type":"CONNECT"}`)

// 2. Ask the platform to launch our receiver app.
send(dstReceiver, nsReceiver, `{"type":"LAUNCH","requestId":1,"appId":"93EC8A58"}`)

// 3. Wait for a RECEIVER_STATUS that carries our app's transportId…
//    {"status":{"applications":[{"appId":"93EC8A58","transportId":"7b6a…"}]}}

// 4. …then CONNECT again — this time to the app itself — so custom-namespace
//    messages reach the page rather than the platform.
send(transportID, nsConnection, `{"type":"CONNECT"}`)

The caveat inside step 3 that will matter enormously in Part 2: LAUNCH is acknowledged when the app is allocated — seconds before the page has actually loaded, run its JavaScript, and registered its message listener. Anything you send in that window is silently dropped by the platform. Hold that thought.



Built by Custavia. Remotype's keyboard and trackpad are free forever; casting is part of Remotype Pro. If this series saves your team the weeks it cost ours, it did its job — and we'd love to hear what you build with it.