Control an LG webOS TV from the command line. Built with Effect
and @effect/cli.
It speaks SSAP — the websocket protocol the official LG remote app uses — so it can do everything that app can: launch apps, switch inputs, change channels, press remote buttons, drive the Magic Remote pointer, type into search boxes, and stream state changes.
- Node 22+
- On the TV: Settings → General → External Devices → LG Connect Apps (called Mobile TV On / TV On with Mobile on some models) must be enabled.
- For
lgtv on, the TV also needs Settings → General → Devices → TV Management → Mobile TV On so it keeps listening for Wake-on-LAN while in standby.
npm install
npm run build
npm link # optional — puts `lgtv` on your PATH
lgtv discover # find TVs on your network
lgtv --host 192.168.1.50 pair # accept the prompt that appears on the TV
lgtv statuspair stores the client key the TV grants, plus its MAC address and whether the connection
used --ssl, in ~/.config/lgtv-remote/config.yaml (mode 0600). After that every command
just works — no --host or --ssl needed.
Without building, run it straight from source with npm run lgtv -- status.
lgtv discover [--for 4] Find LG webOS TVs on the network (SSDP)
lgtv pair Pair and remember the client key
lgtv status Power state, current app, volume, channel
lgtv info Model and firmware
lgtv on [--wait 30] Wake-on-LAN, optionally blocking until it answers
lgtv off Standby
lgtv screen on|off Blank the panel, keep the audio
lgtv volume Show the volume
lgtv volume up|down [steps]
lgtv volume set 20
lgtv mute [on|off|toggle]
lgtv app list [--all]
lgtv app current
lgtv app launch netflix [--content-id ...]
lgtv app close netflix
lgtv youtube <url|video-id> Open a video, short or playlist in the YouTube app
[--start 90] [--app-id ...]
lgtv youtube --search "cello suites" Open the YouTube search screen for a query
lgtv input list
lgtv input set HDMI_1
lgtv channel current|list|up|down
lgtv channel set 7
lgtv media play|pause|stop|rewind|forward
lgtv key HOME UP UP ENTER Remote buttons — see `lgtv keys`
lgtv cursor move 40 -10 | click | scroll 0 -3
lgtv type "planet earth" --enter Type into the focused field
lgtv toast "Dinner is ready"
lgtv watch volume|app|channel|power
lgtv raw ssap://audio/getVolume [--payload '{"…":1}']
lgtv config show|set-host|set-mac|set-ssl|forget
lgtv control [--enter button|ime|auto] [--no-status] Relay your keyboard to the TV, key by key
lgtv repl Drive many commands over one connection
lgtv run "volume set 12" "key HOME" The same, from a script, stopping on the first failure
youtube takes whatever the share sheet gives you — youtu.be/…, a watch?v=… URL, a
short, a /live link, a playlist, or a bare video id — and deep-links it into the TV's
YouTube app, keeping any t= timestamp:
lgtv youtube 'https://youtu.be/dQw4w9WgXcQ?t=90'
lgtv youtube dQw4w9WgXcQ --start 90Quote the URL: & and ? mean something to your shell.
--search opens the app's search screen with the query already filled in, which is the only
practical way to search from the command line — the search box is drawn by the YouTube app
itself, so lgtv type cannot reach it:
lgtv youtube --search "cello suites"lgtv control puts your terminal into raw mode. It relays each key to the TV, like a physical
remote:
lgtv --host 192.168.1.50 control| Key | Action |
|---|---|
| ↑ ↓ ← → | UP DOWN LEFT RIGHT |
| Enter | ENTER, or submit the on-screen keyboard when it has focus |
| Backspace | BACK, or delete one character when the keyboard has focus |
| Esc | BACK |
| Ctrl-↑ / Ctrl-↓ | VOLUMEUP / VOLUMEDOWN |
| Ctrl-← / Ctrl-→ | REWIND / FASTFORWARD |
| PageUp / PageDown | CHANNELUP / CHANNELDOWN |
| Home / End | HOME / EXIT |
| Any other character | Types into the TV's on-screen keyboard |
| Ctrl-K, then a key | One button from the table below |
| Ctrl-C, Ctrl-D | Leave control |
| Ctrl-L | Redraw the status line |
Leader table, reached with Ctrl-K first:
| Key | Button | Key | Button | Key | Button |
|---|---|---|---|---|---|
h |
HOME | m |
MUTE | 0-9 |
number buttons |
b |
BACK | p |
PLAY | R |
RED |
x |
EXIT | Space | PAUSE | G |
GREEN |
i |
INFO | s |
STOP | Y |
YELLOW |
g |
GUIDE | / |
SEARCH | B |
BLUE |
? |
this help |
The colour buttons are shifted, so g stays GUIDE and b stays BACK.
The TV reports whether its on-screen keyboard has focus. This flag decides what Enter and
Backspace do, and the status line shows it live. Use --enter button|ime|auto to override the
flag. The default, auto, trusts the flag.
Two known limits apply. Some apps never raise the system keyboard. The YouTube app's search grid
is one; letters go nowhere there. Use lgtv youtube --search instead. There is also no
key-release event. A held arrow key repeats at your terminal's own rate, not control's.
control shares a connection with lgtv repl the same way every other command does. It cannot
yet run from the lgtv> prompt, though. It refuses with an error there for now — run it from the
plain shell instead.
Every command above also works inside lgtv repl, without the leading lgtv — and unlike the
one-shot form, the whole session shares a single connection and handshake, so key/cursor
reuse one Magic Remote pointer socket instead of reopening it every press:
Start it with VERBOSE=true and every reply the TV sends is echoed as it arrives, above whatever
the command prints — a line that went out and got nothing back is flagged instead:
$ VERBOSE=true lgtv --host 192.168.1.50 repl
Connected to 192.168.1.50. Type a command, or "help".
lgtv> status
← ssap://com.webos.service.tvpower/power/getPowerState {"state":"Active","returnValue":true}
← ssap://com.webos.applicationManager/getForegroundAppInfo {"returnValue":true,"appId":"netflix"}
← ssap://audio/getVolume {"returnValue":true,"volumeStatus":{"volume":12,"muteStatus":false}}
192.168.1.50
power on
app netflix
volume 12
lgtv> key HOME
✓ Sent HOME
← no response
lgtv> exit
key and cursor write to the Magic Remote input socket, which never answers — hence the flag.
The echo goes to stderr, so lgtv --json repl | jq still sees only command output on stdout.
VERBOSE is read once, when the repl starts, and accepts true, 1, yes or on; without it
the prompt behaves exactly as before.
Tab completion, history, and Ctrl-C (cancels the running command, not the session) all work. It also reads from a pipe, running every line and exiting 0 whatever they do:
printf 'status\nvolume up\nvolume\n' | lgtv --host 192.168.1.50 replThe same loop over a fixed sequence, for scripts. It shares the one connection and pointer socket
the way the repl does, but reads nothing from stdin, prints no prompt, and — unlike the repl —
stops at the first command that fails and exits 1, so it can stand in for a && chain:
lgtv run "app launch netflix" "key HOME" "volume set 12"One argument per command, or one argument holding several lines:
lgtv run $'status\nvolume up'Each command is split the way a shell would split it, so quoting works as it does at the prompt
(lgtv run 'youtube --search "planet earth"'). The failing command's error is reported exactly as
the one-shot form reports it, including under --json; the commands after it never run.
| Flag | Meaning |
|---|---|
-H, --host |
TV address. Falls back to $LGTV_HOST, then the saved default. |
--port |
Websocket port. Default 3000, or 3001 with --ssl. |
--ssl |
Use the secure port — some 2023+ models only accept that one. Pair with it once and it is remembered for that TV. |
--no-ssl |
Ignore a remembered --ssl for this run. |
--timeout |
Seconds to wait for a reply. Default 10. |
--json |
Machine-readable output, including errors. Exit code 1 on failure. |
Also read from the environment: LGTV_HOST, LGTV_PORT, LGTV_SSL, LGTV_MAC,
LGTV_CONFIG_DIR, and VERBOSE (echoes the TV's raw replies inside lgtv repl).
lgtv --json status | jq .volumedocs/PROTOCOL.md is a wire-level reference for SSAP itself — framing, the
pairing handshake, every known ssap:// method with its parameters, and the Magic Remote pointer
channel. docs/ARCHITECTURE.md covers how this codebase is put together.
| Layer | What it does |
|---|---|
src/sdk/ |
The SSAP client itself: websocket, pairing handshake, request/response demultiplexing by frame id, subscriptions, response decoders and the Magic Remote input socket. Plain Promises; ws is its only dependency. |
src/domain/ |
Button names, YouTube link parsing, line tokenizing, and every CLI error as data. |
src/services/Settings.ts |
The config file: default host, per-TV MAC, client key and transport. |
src/services/Session.ts |
Resolves flags → env → saved settings into a URL, key and MAC. Resolution is lazy, so discover works before anything is configured. |
src/services/Tv.ts |
Binds the SDK to Effect: failures as typed values, sockets tied to a Scope, subscriptions as Streams, and the client key read through Session. |
src/services/Discovery.ts |
SSDP M-SEARCH, then the UPnP description for the friendly name. |
src/services/Wol.ts |
Wake-on-LAN magic packets, sent to the global and subnet broadcast on ports 9 and 7. |
src/commands/ |
One file per command group. |
Sockets are tied to an Effect Scope, so an interrupted command (Ctrl-C during watch, a
timeout) still closes its connection.
The protocol client is a standalone SDK — no Effect, no CLI, nothing to configure:
import { connect, Uri, VolumeStatus } from "lgtv-remote/sdk"
const tv = await connect({
host: "192.168.0.230",
clientKey: savedKey, // omit on first pairing
onPairingPrompt: () => console.log("accept the prompt on the TV"),
onClientKey: (key) => save(key) // only fires when the key changes
})
await tv.request(Uri.setVolume, { volume: 20 })
const { volume } = await tv.requestAs(Uri.getVolume, VolumeStatus)
for await (const update of tv.updates(Uri.getVolume)) {
console.log(update["volume"])
break
}
const pointer = await tv.pointer()
await pointer.button("HOME")
await tv.close()Failures reject with TvUnreachable, PairingFailed, SsapFailed or UnexpectedResponse —
all instanceof SsapError, each carrying a _tag to switch on. Where the client key is
stored, how a TV is found, and what the user is told are all the caller's business; the SDK
reports the key it was granted and calls back when a prompt goes up.
lgtv2 is the well-known Node library for this protocol, but it is callback-based, ships
no types, and was last published in 2022. The protocol itself is small, so src/sdk/
implements it directly against ws — which buys decoded responses, typed failures, one
socket properly demultiplexed, and a subscription you can for await over. Its pairing
manifest is the same one every third-party remote sends; the TV verifies the signature, so
it must go over the wire verbatim.
npm testtest/fake-tv.ts is a stand-in webOS server: it performs the pairing handshake (prompt
first, then a granted key), answers requests, refuses others with both error frames and
returnValue: false, serves a pointer input socket, and pushes subscription updates. The
suite drives the real client against it, so the protocol code is covered without hardware.
test/contract.test.ts runs one description of the protocol against both the standalone
SDK and the Effect binding, so the two cannot drift apart.
npm run typecheck type-checks the tests too, and npm run lint runs ESLint over the whole
tree (npm run lint:fix to auto-fix). Linting follows @rocket.chat/eslint-config,
with a few Effect-specific rules relaxed in eslint.config.js.
Could not reach the TV — check LG Connect Apps is on. If the TV is a 2023 or newer
model, try --ssl; pairing with it saves the choice, so later commands stay on the secure
port (lgtv config set-ssl off undoes that). If it is in standby, lgtv on first.
Pairing failed: timed out waiting for approval — the prompt appears on the TV itself;
accept it within 60 seconds.
lgtv on does nothing — Wake-on-LAN needs Mobile TV On enabled, a wired connection
or Wi-Fi that supports WoWLAN, and a router that forwards broadcasts. Confirm the stored MAC
with lgtv config show.
A command returns 404 no such service or method — that endpoint does not exist on your
model. lgtv raw is the quickest way to probe alternatives; see
Probing an unknown endpoint for how to read
what comes back, and Confirmed absent on webOS 23
for known-missing endpoints and their replacements.