API reference
ScreenMirror Control API
Drive a ScreenMirror machine from your own code. Move the pointer, press keys, take screenshots, read the words on screen and decide who is in control. Every call is one JSON message on a WebSocket to your relay.
- TransportWebSocket, JSON text frames
- AuthAccount session, or an agent grant
- Checked againstrelay 2.182.1 · Windows host 1.44.1 · Linux host 0.92.1
A browser page, a script or an AI agent. It signs in and sends JSON.
Checks who may send each message, then forwards it to the machine you opened.
Runs on the machine. Injects input, captures the screen and answers.
What you can do
- Point, click, drag and scroll
mouse - Press keys and type in any language
key - Take lossless screenshots
shot - Read the words on screen, with their boxes
shot-ocr - Move clipboard text and files
clipboard - Hold a machine for yourself
control-lock - Run a command and read its output
exec - Let an AI agent in, with a person's yes
join-begin
Tools on this page
Conventions
{relay} stands for your relay's address. On this site it is youcom.duckdns.org, and the samples use it. Machines are numbered with five digits, and the examples use 48213. In the samples, → is a message you send and ← is one you receive. Press / or Ctrl K to search.
// Node 18 or later, with: npm install ws
import WebSocket from 'ws';
const ws = new WebSocket('wss://relay.example.com/view');
const send = (msg) => ws.send(JSON.stringify(msg));
let opened = false;
ws.on('message', (data, isBinary) => {
if (isBinary) return; // video frames
const msg = JSON.parse(data);
if (msg.type === 'auth-required') {
const password = process.env.SM_PASSWORD;
send({ type: 'auth', username: 'dana', password, tfa: 1 });
}
// Signed in: open the machine and take a small picture
if (msg.type === 'host-list' && !opened) {
opened = true;
send({ type: 'select', id: '48213' });
send({ type: 'shot', reqId: 1, mons: [0], len: 4096 });
}
// The machine answered: click the centre of the screen
if (msg.type === 'fs-data' && msg.reqId === 1) {
const at = { x: 0.5, y: 0.5 };
send({ type: 'mouse', action: 'move', ...at });
send({ type: 'mouse', action: 'down', button: 0, ...at });
send({ type: 'mouse', action: 'up', button: 0, ...at });
}
});
Getting started
Connecting
A client keeps one WebSocket open to the relay. It may open a second one just for input.
| Endpoint | Use |
|---|---|
wss://{relay}/view | Main socket. Sign-in, the machine list, video, screenshots and replies. Required. |
wss://{relay}/ctl | Control socket. Optional. Carries input on its own connection, so a click never waits behind video. |
wss://{relay}/ | Where hosts connect. Clients don't use it. |
Open a session
- Open
/view. A relay with accounts sendsauth-required. - Sign in with
auth. You receiveauth-ok, thenhelloandhost-list. - Open a machine with
selectand its number.unselecttakes you back to the list. - Optional: open
/ctland sendctl-attachwith thectlKeyfromhello, within 10 seconds. - Send control messages.
A relay started without accounts skips sign-in. hello and host-list arrive as soon as you connect.
The control socket never replies. A wrong key closes it. Once it is attached, the relay also sends cursor, pong, local-input and perf on it.
→ {"type":"auth","username":"dana","password":"…","tfa":1}
← {"type":"auth-ok","token":"9f2c…","refresh":"41ab…",
"user":"dana","role":"user"}
← {"type":"hello","tag":"v1k","ctl":1,"ctlKey":"5e0d…"}
← {"type":"host-list","role":"user","top":false,"hosts":[…]}
→ {"type":"select","id":"48213"}
→ {"type":"ctl-attach","key":"5e0d…"}
Getting started
Signing in auth
- Routeclient → relay
- WhoAnyone with an account
People sign in with their account. There are no API keys for people. What you may do comes from your account's session at the relay.
| Field | Type | Description |
|---|---|---|
username | string | Account name. Leave it empty for a relay's shared password. |
password | string | The account's password. |
tfa | 1 | Your client can answer a two-step code. Accounts with two-step verification are refused without it. |
token | string | A saved access token. Resumes without a password. |
refresh | string | A saved refresh token. Gets a new access token after the old one expires. |
Replies
| Reply | Meaning |
|---|---|
auth-ok | Signed in. Carries token, refresh, user and role. Keep both tokens. |
auth-2fa | Send the current code from the authenticator app as auth-code. With mode: "enroll" the account must set up two-step verification first. |
auth-bad | Refused. With retryMs, there were too many tries; wait that long. reason: "suspended" comes with the administrator's note. |
auth-expired | The saved tokens no longer work. Sign in with the password. |
Access tokens last 60 minutes. The refresh token renews them without a password, also on sibling relays that share the account database.
→ {"type":"auth","token":"9f2c…","refresh":"41ab…"}
← {"type":"auth-ok","token":"c07e…","refresh":"41ab…",
"user":"dana","role":"user"}
→ {"type":"auth","username":"dana","password":"…","tfa":1}
← {"type":"auth-2fa","user":"dana","mode":"code"}
→ {"type":"auth-code","code":"482913"}
← {"type":"auth-ok","token":"9f2c…","refresh":"41ab…",
"user":"dana","role":"user"}
Getting started
Permissions
Each account can open a set of machines. On a machine, your role decides what you may send.
| Who | May send |
|---|---|
| Anyone who can open the machine | mouse, key, clipboard, clip-*, shot, shot-get, shot-ocr, exec, key-route, control-state, files (fs-*) and the terminal (term-*) |
| Administrator | All of the above, plus privacy and control-lock |
| Top-tier administrator | All of the above, plus input-guard, presence alerts and private machines |
| AI agent with a grant | Only what its grant names. See Grant scopes. |
A private machine is visible to top-tier administrators only. To everyone else it answers exactly like a machine that doesn't exist.
A refused message gets access-denied with a why, or no reply at all. Confirm what happened by looking at the screen, not by the fact that you sent it.
Concepts
Message format
Every message is one JSON object in one WebSocket text frame.
- Put
typefirst. Hosts look for the literal text"type":"…", and some expect it at the very start of a request. - Keep objects flat. The host finds fields by name. A nested object that reuses a field name can be read as the outer one.
- Number your requests. A message that expects an answer carries a numeric
reqId. The reply repeats it, and the relay delivers that reply to you alone. - Expect new fields. Replies gain fields over time. Ignore the ones you don't know.
Some types belong to the relay. stream-start, stream-stop, keyframe, abr, udp-info, input-release and the results of pairing and joining are among them.
The relay checks the raw text of every frame it forwards to a host. If the characters "type":"keyframe" appear anywhere in your message, even inside a string, the whole frame is dropped.
→ {"type":"exec","reqId":12,"cmd":"hostname"}
← {"type":"exec-result","reqId":12,"code":0,
"data":"V09SS1NUQVRJT04tMDcNCg=="}
data is base64 for WORKSTATION-07 and a line break. Paste it into the Reply decoder to read it.Concepts
View-only machines
A host can run with remote input switched off. It still streams and answers questions, but it injects nothing. Here is what happens to each kind of message.
| Behaviour | Messages |
|---|---|
| Dropped, no reply | mouse, key, clipboard, file-* |
| Refused with a reason | exec, key-route, input-guard |
| Work as usual | shot, shot-get, shot-ocr, lock, keep-desktop, privacy, wol-check |
Input on a view-only machine disappears without an error. After any action that matters, take a shot and check the screen.
← {"type":"exec-result","reqId":13,"code":-1,
"error":"remote input is disabled on the host"}
← {"type":"key-route-state","on":false,
"reason":"remote input is switched off on this host"}
Concepts
Coordinates
Pointer positions are fractions of the picture, from 0 to 1. They are never pixels.
- Divide by the picture's size:
x = px / wandy = py / h. A screenshot's first reply gives youwandh. - Round to five decimal places.
- Stay below 1 by clamping to
0.99999. Exactly1is one pixel past the edge, or the first pixel of the next screen. - Outside the picture, send nothing.
- Take screenshots of the same screens the stream shows you, so the fractions mean the same thing.
How the host places the pointer
It checks these cases in order.
- One stream per screen. When the host encodes each screen on its own, the fraction covers that whole screen. The relay adds
monfor you. - A cropped view. If you were showing part of the screen, the fraction is inside that crop. Send
full: 1if you already converted it to the whole screen. - A merged wall. The fraction covers every screen, and the pointer crosses from one to the next.
- A single screen. The fraction covers that screen.
While the host rebuilds its capture, for example after a resolution change, it refuses moves so the pointer can't jump to a corner. Button releases still go through, so a drag never leaves a button held.
function toWire(px, py, shot) {
const f = (v) => Math.min(Math.max(v, 0), 0.99999);
const r = (v) => Math.round(v * 1e5) / 1e5;
return { x: r(f(px / shot.w)), y: r(f(py / shot.h)) };
}
toWire(1280, 720, { w: 2560, h: 1440 }); // { x: 0.5, y: 0.5 }
toWire(2560, 0, { w: 2560, h: 1440 }); // { x: 0.99999, y: 0 }
Coordinate picker
Click or drag on the screen to get the numbers to send.
Input
Pointer mouse
- Routeclient → host
- View-only machineDropped
- WhoAny viewer · agents with Drive
Moves, presses, releases and scrolls. One message becomes one input event on the machine.
| Field | Type | Description |
|---|---|---|
action | string | move, down, up, wheel or hwheel. |
x, y | number | Position from 0 to 1. See Coordinates. Required except on a release in place. |
button | integer | 0 left, 1 middle, 2 right, 3 back, 4 forward. The same numbers as the DOM's MouseEvent.button. |
delta | integer | Wheel only. 120 per notch, at most ±1200. For wheel, positive scrolls up. For hwheel, positive scrolls right. |
drag | 1 | On moves while a button is held. Without it, the host may skip to the newest point of a burst. |
m | integer | Held modifiers: 1 Ctrl, 2 Shift, 4 Alt, 8 Win. Never on a move. |
full | 1 | The position is already in whole-screen space. |
mon | integer | Screen index. The relay sets it for you. |
Behaviour you can rely on
- An unknown
buttondoes nothing. It never falls back to a left click. - Clicks and wheel steps move the pointer first, so they land where you aimed.
- Your first
mouseorkeymarks you as driving, and other viewers see it. - If another operator used input in the last 5 seconds, the relay removes
m. One person's Ctrl never changes another person's click. - When the host offers a UDP path, the relay sends your moves over it. You don't need to do anything.
→ {"type":"mouse","action":"move","x":0.5,"y":0.5}
→ {"type":"mouse","action":"down","button":0,"x":0.5,"y":0.5}
→ {"type":"mouse","action":"up","button":0,"x":0.5,"y":0.5}
→ {"type":"mouse","action":"down","button":0,"x":0.31,"y":0.12}
→ {"type":"mouse","action":"move","x":0.38,"y":0.15,"drag":1}
→ {"type":"mouse","action":"move","x":0.45,"y":0.18,"drag":1}
→ {"type":"mouse","action":"up","button":0,"x":0.45,"y":0.18}
→ {"type":"mouse","action":"wheel","x":0.5,"y":0.62,"delta":-360}
Input
Keyboard key
- Routeclient → host
- View-only machineDropped
- WhoAny viewer · agents with Drive
Presses and releases keys. Send a down and then an up for every key.
| Field | Type | Description |
|---|---|---|
action | string | down, up, or sas for Ctrl+Alt+Del. |
code | string | The DOM's KeyboardEvent.code, such as KeyA, Enter, F4 or ControlLeft. Empty when you type a character with text. |
text | string | One character. |
sc | integer | PC set-1 scan code of the key's position, such as 16 for KeyQ. Only for keys whose character depends on the layout. |
m | integer | Held modifiers, on down only. Same bits as mouse. |
How the host picks the key
- By position, with
sc. For letters, digits, punctuation andAltRight. The host presses that physical key and its own layout picks the character, soKeyQtypes "a" on a French machine. - By name, with
code. For keys that mean the same on every layout: Enter, arrows, F-keys and modifiers. - By character, with
code: ""andtext. Skips the layout entirely, so accents and any script arrive exactly as sent.
Behaviour you can rely on
- A release undoes its own press. If you pressed by position, the matching
upreleases that position, whatever theupsays. - A line feed or tab in
textis sent as the Enter or Tab key. Most Windows programs don't treat a raw line feed as Enter. sasraises Ctrl+Alt+Del through the host's service. It can't be pressed as three keys.- Keys you leave down are released when the stream stops, or when the relay tells the host to let go.
- The relay adds
vidto every key so the host can tell viewers apart. Don't send it yourself.
→ {"type":"key","action":"down","code":"Enter"}
→ {"type":"key","action":"up","code":"Enter"}
→ {"type":"key","action":"down","code":"ControlLeft"}
→ {"type":"key","action":"down","code":"KeyC","sc":46,"m":1}
→ {"type":"key","action":"up","code":"KeyC","sc":46}
→ {"type":"key","action":"up","code":"ControlLeft"}
→ {"type":"key","action":"down","code":"","text":"é"}
→ {"type":"key","action":"up","code":"","text":"é"}
→ {"type":"key","action":"sas"}
Key recorder
Press a shortcut and get the exact messages the viewer would send.
Click Record, then press the keys. It stops after one shortcut.
Input
Typing text
- Built from
keymessages
There is no message for a block of text. Type it as key messages, one character at a time. The viewer's auto-type works the same way.
- Walk the text by code point, so an emoji is one step and a surrogate pair stays whole.
- Send each character as
code: ""withtext: adown, then anup. - Send line breaks and tabs as the
EnterandTabkeys. - Release Shift and Ctrl before the first character. Don't send a lone Alt or Win release, because it opens a menu or Start.
- Leave at least 40 ms between presses.
- Stop when you lose Control, or when the machine reports someone typing at it (
local-input).
The viewer's pacing
| Speed | Exact | Like a person |
|---|---|---|
| Slow | 310 ms | 205 ms |
| Normal | 180 ms | 112 ms |
| Fast | 120 ms | 72 ms |
Average time per key. "Like a person" holds each key about 90 ms, keeps the rhythm of recorded typing and makes occasional corrected typos (1.0 to 2.6%).
const NAMED = { '\n': 'Enter', '\t': 'Tab' };
// Walks the text one code point at a time
async function typeText(send, text, gapMs = 60) {
for (const ch of text) {
const key = NAMED[ch]
? { code: NAMED[ch] }
: { code: '', text: ch };
send({ type: 'key', action: 'down', ...key });
send({ type: 'key', action: 'up', ...key });
await new Promise((r) => setTimeout(r, gapMs));
}
}
Text to keys
Type any text and get the key messages that type it.
Input
Clipboard text clipboard
- Routeboth ways
- View-only machineDropped
- WhoAny viewer · not agents
Sets the machine's clipboard text. The machine sends you its clipboard when it changes.
| Field | Type | Description |
|---|---|---|
text | string | The text, up to 1 MB. |
From the machine you receive clipboard with text, or clipboard-files with a files list. Your own write is not echoed back.
A write can fail without a reply. If another program holds the clipboard, the host gives up after about 300 ms. A Ctrl+V sent after that pastes whatever was there before, which can be someone else's data.
For anything that matters, type the text with key messages, or check the screen after pasting.
→ {"type":"clipboard","text":"Quarterly figures, final"}
← {"type":"clipboard","text":"copied on the machine"}
Input
Clipboard files clip-*
- Routeboth ways
- View-only machineAnswered
- WhoAny viewer · not agents
Files are offered, not pushed. You advertise them, and the machine asks for the bytes when someone pastes.
| Message | Route | Purpose |
|---|---|---|
clip-offer | you → host | Advertise files: token and files, a list of {name, size}. With clear: true, withdraw the offer. |
clip-pull | host → you | The machine is reading file index. Stream it from byte from. |
clip-push | you → host | One chunk: token, index, offset and base64 data. Mark the end with eof: true, or fail with error. |
clip-ack | host → you | Bytes received so far, in upto. Keep a window of unacknowledged chunks. |
clip-cancel | host → you | Stop sending. |
The token is yours alone. Every viewer of the machine sees the offer, but only the client that holds the token can serve it.
→ {"type":"clip-offer","token":"co7f3a…",
"files":[{"name":"report.pdf","size":482113}]}
← {"type":"clip-pull","token":"co7f3a…","index":0,"from":0}
→ {"type":"clip-push","token":"co7f3a…","index":0,"offset":0,
"data":"JVBERi0xLjcK…"}
← {"type":"clip-ack","token":"co7f3a…","index":0,"upto":65536}
→ {"type":"clip-push","token":"co7f3a…","index":0,
"offset":482113,"eof":true}
→ {"type":"clip-offer","token":"co7f3a…","clear":true}
Screen
Screenshots shot shot-get
- Routeclient → host
- View-only machineWorks
- WhoAny viewer · agents
Takes a lossless PNG of one or more screens and returns it in base64 slices. The first reply carries the size, and you pull the rest.
shot
| Field | Type | Description |
|---|---|---|
reqId | integer | Your request number. |
mons | integer[] | Screens to capture, such as [0], or [0,1] for a merged wall. |
len | integer | Bytes per slice, from 4,096 to 1,048,576. Default 32,768. |
shot-get
| Field | Type | Description |
|---|---|---|
reqId | integer | Your request number. |
id | integer | The picture id from the first reply. |
offset | integer | Where the slice starts, in bytes. |
len | integer | Bytes per slice, as above. |
Reply: fs-data
| Field | Type | Description |
|---|---|---|
id, w, h | integer | First slice only. Picture id, and its size in pixels. |
total | integer | Size of the whole PNG in bytes. |
offset | integer | Where this slice starts. |
eof | boolean | true on the last slice. |
data | string | The slice, in base64. |
error | string | Sent instead of data when the request failed. |
Limits
- The host keeps a picture for 10 minutes, holding the newest 3 and at most 64 MB in all.
- Up to 4 screenshot or reading jobs wait at once. More are refused with "too many screenshots at once".
- A shot always covers whole screens.
lenlimits bytes on the wire only. - On a host running as a service, sign-in and UAC screens are captured too.
// 1. Take it. The reply is the first slice and the size.
→ {"type":"shot","reqId":7,"mons":[0],"len":32768}
← {"type":"fs-data","reqId":7,"offset":0,"total":1482301,
"eof":false,"id":91,"w":2560,"h":1440,
"data":"iVBORw0KGgo…"}
// 2. Pull the rest. The viewer keeps 8 of these in flight.
→ {"type":"shot-get","reqId":8,"id":91,
"offset":32768,"len":32768}
← {"type":"fs-data","reqId":8,"offset":32768,"total":1482301,
"eof":false,"data":"…"}
← {"type":"fs-data","reqId":30,"offset":65536,
"error":"that screenshot is no longer held -- take it again"}
Screen
Text recognition shot-ocr
- Routeclient → host
- View-only machineWorks
- WhoAny viewer · agents may read
Reads the words in a region of a picture the host is holding. The picture stays on the machine, and only the words come back.
| Field | Type | Description |
|---|---|---|
reqId | integer | Your request number. |
id | integer | Picture id from shot. |
x, y, w, h | integer | The region, in the picture's pixels. Leave them out to read the whole picture. |
pw, ph | integer | The picture's size. Always send them: a stale id then answers gone: 1 instead of reading a different picture. |
lang | string | A language tag such as en-US. English by default. |
The reply
An fs-data with an ocr object. Each line has its text in t and its words in w. A word is [text, x, y, width, height], measured from the region's top-left corner. Add the region's x and y to get picture pixels.
Two more forms
{"type":"shot-ocr","reqId":10,"langs":1}lists the languages the machine can read.{"type":"shot-ocr","reqId":11,"add":"ja-JP"}installs a language through Windows Update, which can take up to 30 minutes. Never allowed on an agent grant.
Windows hosts use the recognizer built into Windows. Linux hosts use Tesseract, which must be installed on the machine. Without it, a read answers with an error that says so.
→ {"type":"shot-ocr","reqId":9,"id":91,"x":0,"y":0,"w":800,"h":40,
"pw":2560,"ph":1440,"lang":"en-US"}
← {"type":"fs-data","reqId":9,"eof":true,"ocr":{
"lang":"en-US","ms":38,"scale":2,
"x":0,"y":0,"w":800,"h":40,
"lines":[{"t":"File Edit View","w":[
["File",12,8,28,14],["Edit",52,8,30,14],
["View",94,8,34,14]]}]}}
← {"type":"fs-data","reqId":12,"eof":true,
"error":"that screenshot is no longer held -- take it again",
"ocr":{"n":0,"gone":1}}
Screen
Reading cheaply
Take one picture, then ask about parts of it. A full 4K PNG is megabytes; the words in a dialog are a few hundred bytes.
- Send
shotwithlen: 4096. Keepid,wandhfrom the first reply and ignore its bytes. That costs about 5.5 KB. - Send
shot-ocragainst thatidfor each region you care about. No new capture happens. - Pull PNG slices only when you need pixels. PNG stores rows top to bottom, so the first slices decode to the top of the picture.
The host still captures and encodes whole screens for every shot. Taking one picture and reading it many times is what saves the work.
→ {"type":"shot","reqId":20,"mons":[0],"len":4096}
← {"type":"fs-data","reqId":20,"id":92,"w":1920,"h":1080,
"total":611204,…}
→ {"type":"shot-ocr","reqId":21,"id":92,"x":0,"y":0,"w":1920,
"h":48,"pw":1920,"ph":1080}
→ {"type":"shot-ocr","reqId":22,"id":92,"x":660,"y":380,
"w":600,"h":320,"pw":1920,"ph":1080}
Control
Driving control-state
- Routeclient → relay
- WhoAny viewer · agents
Tells the relay you are driving the machine, or that you have stopped. Every viewer of the machine sees who is driving.
| Field | Type | Description |
|---|---|---|
on | boolean | true to drive, false to stop. |
- You rarely need it. Your first
mouseorkeyturns it on. - If someone holds the machine exclusively, the relay answers
control-revokedwith their name inby.
→ {"type":"control-state","on":true}
→ {"type":"control-state","on":false}
// While someone holds the machine exclusively
→ {"type":"control-state","on":true}
← {"type":"control-revoked","by":"alex"}
Control
Exclusive control control-lock
- Routeclient → relay
- WhoAdministrators · agents with Drive
Holds the machine for you alone. The relay drops every other operator's input until you release it or your connection ends.
| Field | Type | Description |
|---|---|---|
on | boolean | true to take it, false to release it. |
- Ignored while someone else holds the lock.
- Ignored while a top-tier administrator's Solo blocks other operators.
- An agent's lock ends with its visit. Ask the people watching before you take it, and release it when you are done.
→ {"type":"control-lock","on":true}
→ {"type":"control-lock","on":false}
Control
Solo input-guard
- Routeclient → relay → host
- View-only machineRefused
- WhoTop-tier administrators
Blocks input at the machine, from other operators, or both. Solo is a setting of the machine, not of your session. It survives a restart and holds while nobody is watching.
| Field | Type | Description |
|---|---|---|
on | boolean | Turn Solo on or off. |
local | boolean | Block the keyboard, mouse, pen and touch at the machine. Default true. |
remote | boolean | Block every other operator, and other remote-control tools. |
key | string | The release chord, typed at the machine. Modifier names and a key joined by +. Default ctrl+alt+shift+f11. |
- To change
localorremote, turn Solo off and on again. - A top-tier administrator releases it, or the chord typed at the machine.
- Ctrl+Alt+Del always works at the machine.
→ {"type":"input-guard","on":true,"local":true,"remote":false,
"key":"ctrl+alt+shift+f11"}
→ {"type":"input-guard","on":false}
← {"type":"input-guard","on":false,"by":null,"tag":null,
"reason":"Solo is for top-tier administrators"}
Control
Privacy screen privacy
- Routeclient → relay → host
- View-only machineWorks
- WhoAdministrators
- NeedsWindows 10 2004 or later
Blanks the machine's own monitors while your stream keeps showing the real desktop. The person at the machine sees a black screen.
| Field | Type | Description |
|---|---|---|
on | boolean | Blank the monitors, or bring them back. |
- It lasts for the session. When the last viewer leaves, the monitors come back, so nobody is left in front of a black screen with no one on the other end.
- Refused while another operator holds the machine exclusively.
→ {"type":"privacy","on":true}
→ {"type":"privacy","on":false}
// Refused while another operator holds the machine
← {"type":"privacy","on":false,"by":null,
"reason":"another operator holds this machine exclusively"}
Control
Key routing key-route
- Routeclient → host
- View-only machineRefused
- WhoAny viewer
Sends your typing to one window without moving the focus. A person at the machine can keep typing somewhere else at the same time.
| Field | Type | Description |
|---|---|---|
on | 1 or 0 | 1 pins the window under your pointer. 0 lets it go. |
Hover the field you want, then send on: 1. The host answers key-route-state with on, your vid, and a reason when it refuses.
It only redirects your own keys and never blocks anyone, so any viewer may use it.
→ {"type":"key-route","on":1}
← {"type":"key-route-state","on":true,"vid":"v1k"}
→ {"type":"key-route","on":0}
← {"type":"key-route-state","on":false,"vid":"v1k",
"reason":"no window under the pointer to route keys to"}
Machine
Run a command exec
- Routeclient → host
- View-only machineRefused
- WhoAny viewer · agents with Run commands
Runs one command on the machine and returns its exit code and output.
| Field | Type | Description |
|---|---|---|
reqId | integer | Your request number. |
cmd | string | The command line. |
Reply: exec-result
| Field | Type | Description |
|---|---|---|
code | integer | The exit code. -1 when the command did not run. |
data | string | Everything it printed, in base64. |
truncated | boolean | true when the output passed 512 KB. |
error | string | Why it did not run. |
- Windows runs it with
cmd.exe /c. Call PowerShell yourself, as inpowershell -NoProfile -Command "…". Linux runs it with/bin/sh -c. - The host stops a command after 10 seconds and keeps the first 512 KB of output.
- Commands run on their own worker, so a slow one never holds up input. When the queue is full, the reply says the host is busy.
- Remote sign-out and restart are commands too.
→ {"type":"exec","reqId":12,"cmd":"hostname"}
← {"type":"exec-result","reqId":12,"code":0,
"data":"V09SS1NUQVRJT04tMDcNCg=="}
← {"type":"exec-result","reqId":13,"code":-1,
"error":"the host is busy running another command"}
Machine
Lock and wake
These change the machine's state without injecting any input, so they work on view-only machines too.
| Send | Reply | What it does |
|---|---|---|
lock | lock-result | Locks the workstation. The reply has ok. |
keep-desktop | none | Keeps this session's desktop on the machine's own screen, unlocked. |
wol-check | wol-status | Asks whether this machine is set up to be woken over the network. The reply has enabled. |
wake | wake-status | Asks the relay to wake an offline machine by its id. A machine that is online on the same network sends the wake packet. A failure carries reason: no-mac or no-lan-host. |
→ {"type":"wake","id":"48213"}
← {"type":"wake-status","id":"48213","ok":false,
"reason":"no-lan-host"}
Agents
Joining a machine join-begin
- Routeclient → relay
- WhoAnyone with the join key
An AI agent has no account. It asks to visit one machine, and a person watching that machine says yes or no. Nothing gets in without that press, unless an owner approved the key in advance.
- Open
/view. Don't sign in. - Send
join-beginwith the relay's join key and the machine number. - You receive a
join-code. Everyone watching the machine sees a card with the same code, your name and your task. - The first person to answer decides, within 2 minutes. Yes gives you
auth-ok. No gives youjoin-nakwithdenied. selectthe machine and work within your scopes.- Send
join-leavewhen you are done.
| Field | Type | Description |
|---|---|---|
key | string | The relay's join key, SM_JOIN_KEY. It gives the right to ask, nothing more. |
host | string | The five-digit machine number. |
agent | string | claude, gpt, gemini or agent, shown as "Claude (agent)" and so on. Anything else shows as "AI (agent)". |
task | string | What you want to do, shown on the card. Up to 200 characters. |
control | 1 | Also ask for the keyboard and mouse. |
shell | 1 | Also ask to run commands. |
When a visit ends
- After 60 minutes. The relay's
SM_JOIN_MINUTESchanges this, from 5 to 1440. - When an operator ends it.
- When you send
join-leave, which answersjoin-done. The other two end withaccess-denied.
A join-code with auto: 1 means an owner approved this key for these scopes in advance. You are let in with no card.
A complete client lives in the repository at tools/agent-drive. core/agent-join.js joins, waits for the press, and runs commands dropped into a folder.
→ {"type":"join-begin","key":"…","host":"48213","agent":"claude",
"task":"Fill in the expense form","control":1}
← {"type":"join-code","code":"7KQ2-MX9D","expires":1793282520000}
// A person watching 48213 presses Allow
← {"type":"auth-ok","token":"…","refresh":"…",
"user":"Claude (agent)","role":"user"}
→ {"type":"select","id":"48213"}
→ {"type":"shot","reqId":1,"mons":[0],"len":4096}
// Finished
→ {"type":"join-leave"}
← {"type":"join-done","ok":true,"left":true}
Agents
Grant scopes
A grant names what the agent may do on one machine. Each scope is its own consent, written on the card in plain words.
| Scope | Ask with | Allows |
|---|---|---|
| Watch | always | select, unselect, shot, shot-get, shot-ocr, resync, active, idle, ping, pong, control-state, chat, chat-typing, chat-read, drive-status, spot, logout, join-leave |
| Drive | control: 1 | mouse, key, control-lock |
| Run commands | shell: 1 | exec |
Never on a grant
key with action: "sas", shot-ocr with add, clipboard, clip-*, file-*, fs-*, term-*, lock, privacy, input-guard, and every account action such as pairing, sessions and preferences.
Rules the relay enforces
- A grant never grows. A visit let in to watch stays a visit to watch. No message adds a scope later.
- It never exceeds the person who approved it. If they lose access to the machine, the visit loses it too.
- Expiry is checked on every message, not only when the agent arrives.
- The agent's tokens work only on the socket that was let in. Replaying them on a new connection is refused.
- Every grant and release is written to the audit log, with the approver's name.
Agents
Refusals join-nak
| Reason | Meaning |
|---|---|
off | The relay has no join key set, so joining is off. |
no | Wrong key. |
no-host | No such machine here, it is private, or it is offline. May carry relays, sibling relays to try next. |
nobody | Nobody is watching the machine, so there is no one to ask. |
denied | A person said no. |
slow-down | Too many requests from your address. |
no, no-host and nobody all arrive after the same 400 ms, so timing can't tell them apart.
During a visit
| Reply | Meaning |
|---|---|
access-denied | why: "an agent may not do that". The message is outside your scopes. |
access-denied | why: "that visit has ended". Your time ran out. |
access-denied | why: "an operator ended this visit". |
← {"type":"join-nak","reason":"no-host",
"relays":["wss://relay-2.example.com"]}
→ {"type":"clipboard","text":"…"}
← {"type":"access-denied","why":"an agent may not do that"}
HTTP
Machine status
- MethodGET
- AuthNone
- EffectRead only
The relay's one HTTP endpoint answers a machine's status as JSON. It needs no sign-in.
Add ?fresh=1 to skip the cache.
| Field | Description |
|---|---|
id, name | The machine's number and name. |
platform, version | Its operating system family and host build. |
ip, ip_from | Its public address as the relay sees it, and how that was learned. |
lan_ip | Its addresses on its own network. |
cached, age_ms, ttl_ms | Whether this came from the cache, how old it is, and how long it is kept. |
info | The full machine card: OS, uptime, CPU, memory, disks, GPUs, screens, network adapters, local accounts, the administrators group, the board and the battery. |
Status codes
| Status | error | Meaning |
|---|---|---|
| 200 | none | The status. |
| 404 | no-such-host | No machine with that number is online, or it is private. |
| 404 | disabled | The relay runs with SM_SYSINFO_PUBLIC=0. |
| 429 | rate-limited | More than 20 requests a minute from your address. |
| 502 | gather-failed | The machine did not answer in time. |
| 502 | relay-unreachable | The sibling relay holding the machine did not answer. |
This endpoint is public by design. Its answer includes MAC addresses, local account names and the administrators group, and machine numbers are easy to guess.
Turn it off with SM_SYSINFO_PUBLIC=0, or leave out the addresses with SM_SYSINFO_IP=0. SM_SYSINFO_TTL_SEC sets the cache time and SM_SYSINFO_RATE the per-address limit.
There is no HTTP control API. Input, screenshots and commands only travel over the signed-in WebSocket.
RELAY=https://relay.example.com curl -s "$RELAY/api/host/48213/sysinfo" curl -s "$RELAY/api/host/48213/sysinfo?fresh=1"
{
"ok": true,
"id": "48213",
"name": "WORKSTATION-07",
"platform": "windows",
"version": "1.44.1",
"online": true,
"ip": "203.0.113.24",
"ip_from": "peer",
"lan_ip": ["192.168.1.40"],
"cached": false,
"age_ms": 0,
"ttl_ms": 60000,
"at": 1793282400000,
"info": {…}
}
Ask a relay
Send a real request and see the answer.
Tools
Message builder
Pick a message, fill in its fields and copy it, ready to send. It flags values the host would refuse or change.
Tools
Reply decoder
Paste what you received to see what it means. Command output, recognised text and small screenshots are decoded for you. Nothing leaves this page.
Reference
Errors
Refusals arrive as ordinary messages. These are the ones to handle. The Reply decoder explains each of them.
| Reply | When | What to do |
|---|---|---|
access-denied | You may not send that, or your visit ended. why says which. | Check your role or grant. An ended visit needs a new join. |
auth-bad | Sign-in refused. retryMs means locked out for now. | Check the credentials, or wait retryMs. |
auth-expired | The saved tokens no longer work. | Sign in with the password. |
control-revoked | Someone holds the machine exclusively. by names them. | Wait, or ask them. |
join-nak | An agent's join was refused. | See Refusals. |
fs-data + error | A screenshot or reading failed. With gone: 1, the picture is no longer held. | Take a new shot. |
exec-result, code -1 | The command did not run. error says why. | Wait if busy. Remote input must be on. |
key-route-state | on: false with a reason. | Hover a window and arm again. |
input-guard, privacy | on: false with a reason. | Check your tier, or who holds the machine. |
wake-status | ok: false with no-mac or no-lan-host. | The relay knows no address for it, or no machine on its network is online. |
| No reply | Input on a view-only machine, a relay-only type, or a wrong control-socket key. | Take a shot and look. |
Reference
Limits
| What | Limit |
|---|---|
| Access token | 60 minutes |
| Control socket attach | 10 seconds after connecting |
| Join code | 2 minutes |
| Agent visit | 60 minutes (5 to 1,440) |
| Screenshot slice | 4 KB to 1 MB, default 32 KB |
| Pictures held | newest 3, 64 MB, 10 minutes |
| Screenshot and reading queue | 4 jobs |
| Wheel step | 120 per notch, ±1,200 per message |
| Clipboard text | 1 MB |
| Command | 10 seconds, 512 KB of output |
| Co-driving window | 5 seconds |
| Machine status cache | 60 seconds |
| Machine status rate | 20 a minute per address |
Reference
Message index
Every message type a client works with, and what a view-only machine does with it.
| Type | Route | View-only machine | Purpose |
|---|---|---|---|
| Session | |||
auth, auth-code | client → relay | n/a | Sign in |
select, unselect | client → relay | n/a | Open or leave a machine |
ctl-attach | client → relay | n/a | Attach the control socket |
ping, pong | both | n/a | Liveness |
| Input | |||
mouse | client → host | Dropped | Pointer |
key | client → host | Dropped | Keys, and Ctrl+Alt+Del |
clipboard | both | Dropped | Clipboard text |
file-* | client → host | Dropped | Uploads |
clip-* | both | Answered | Clipboard files |
key-route | client → host | Refused | Route your typing to one window |
| Screen | |||
shot, shot-get | client → host | Works | Screenshot |
shot-ocr | client → host | Works | Text recognition |
monitor, monitor-merge | client → host | Works | Screen choice and merged wall |
virtual-display, display-mode | client → host | Works | Virtual screens |
quality, codec, cursor-mode | client → host | Works | Stream settings |
resync | client → relay | Works | Ask for a fresh full frame |
| Control | |||
control-state | client → relay | n/a | Say you are driving |
control-lock | client → relay | n/a | Exclusive control |
input-guard | client → relay → host | Refused | Solo |
privacy | client → relay → host | Works | Privacy screen |
| Machine | |||
exec | client → host | Refused | Run one command |
lock, keep-desktop | client → host | Works | Workstation lock and desktop |
wol-check | client → host | Works | Can this machine be woken? |
wake | client → relay | n/a | Wake an offline machine |
fs-*, term-* | client → host | Answered | File manager and terminal |
| Agents | |||
join-begin | agent → relay | n/a | Ask to visit a machine |
join-leave | agent → relay | n/a | End the visit |
join-allow, join-deny, join-revoke | client → relay | n/a | A person answers or ends a visit |
| Relay only. A client frame that contains these is dropped. | |||
stream-start, stream-stop, keyframe, abr, udp-info | relay → host | n/a | Stream control |
input-release | relay → host | n/a | Let go of held input |
wake-send, presence-alerts, monitors, permon | relay → host | n/a | Sent on a viewer's or administrator's behalf |
update-*, host-challenge | relay → host | n/a | Host updates and host sign-in |