ScreenMirror Control API
relay 2.182.1

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

What you can do

Tools on this page

Conventions

{relay} stands for your relay's address. 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.

Quick start: sign in, open a machine, click its centreNode.js
// 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.

EndpointUse
wss://{relay}/viewMain socket. Sign-in, the machine list, video, screenshots and replies. Required.
wss://{relay}/ctlControl 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

  1. Open /view. A relay with accounts sends auth-required.
  2. Sign in with auth. You receive auth-ok, then hello and host-list.
  3. Open a machine with select and its number. unselect takes you back to the list.
  4. Optional: open /ctl and send ctl-attach with the ctlKey from hello, within 10 seconds.
  5. 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.

Sign in and open a machine/view
→ {"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"}
Attach the control socket/ctl
→ {"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.

FieldTypeDescription
usernamestringAccount name. Leave it empty for a relay's shared password.
passwordstringThe account's password.
tfa1Your client can answer a two-step code. Accounts with two-step verification are refused without it.
tokenstringA saved access token. Resumes without a password.
refreshstringA saved refresh token. Gets a new access token after the old one expires.

Replies

ReplyMeaning
auth-okSigned in. Carries token, refresh, user and role. Keep both tokens.
auth-2faSend the current code from the authenticator app as auth-code. With mode: "enroll" the account must set up two-step verification first.
auth-badRefused. With retryMs, there were too many tries; wait that long. reason: "suspended" comes with the administrator's note.
auth-expiredThe 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.

Resume with saved tokensJSON
→ {"type":"auth","token":"9f2c…","refresh":"41ab…"}
← {"type":"auth-ok","token":"c07e…","refresh":"41ab…",
   "user":"dana","role":"user"}
Two-step verificationJSON
→ {"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.

WhoMay send
Anyone who can open the machinemouse, key, clipboard, clip-*, shot, shot-get, shot-ocr, exec, key-route, control-state, files (fs-*) and the terminal (term-*)
AdministratorAll of the above, plus privacy and control-lock
Top-tier administratorAll of the above, plus input-guard, presence alerts and private machines
AI agent with a grantOnly 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 type first. 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.

A request and its replyJSON
→ {"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.

BehaviourMessages
Dropped, no replymouse, key, clipboard, file-*
Refused with a reasonexec, key-route, input-guard
Work as usualshot, 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.

What a refusal looks likeJSON
← {"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 / w and y = py / h. A screenshot's first reply gives you w and h.
  • Round to five decimal places.
  • Stay below 1 by clamping to 0.99999. Exactly 1 is 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.

  1. One stream per screen. When the host encodes each screen on its own, the fraction covers that whole screen. The relay adds mon for you.
  2. A cropped view. If you were showing part of the screen, the fraction is inside that crop. Send full: 1 if you already converted it to the whole screen.
  3. A merged wall. The fraction covers every screen, and the pointer crosses from one to the next.
  4. 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.

A pixel in a screenshot to wire coordinatesJS
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 }
Try it

Coordinate picker

Click or drag on the screen to get the numbers to send.

0.5 x0.5 y
Click messages

            

    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.

    FieldTypeDescription
    actionstringmove, down, up, wheel or hwheel.
    x, ynumberPosition from 0 to 1. See Coordinates. Required except on a release in place.
    buttoninteger0 left, 1 middle, 2 right, 3 back, 4 forward. The same numbers as the DOM's MouseEvent.button.
    deltaintegerWheel only. 120 per notch, at most ±1200. For wheel, positive scrolls up. For hwheel, positive scrolls right.
    drag1On moves while a button is held. Without it, the host may skip to the newest point of a burst.
    mintegerHeld modifiers: 1 Ctrl, 2 Shift, 4 Alt, 8 Win. Never on a move.
    full1The position is already in whole-screen space.
    monintegerScreen index. The relay sets it for you.

    Behaviour you can rely on

    • An unknown button does 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 mouse or key marks 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.
    Left click at the centreJSON
    → {"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}
    Drag a window by its title barJSON
    → {"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}
    Scroll down three notchesJSON
    → {"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.

    FieldTypeDescription
    actionstringdown, up, or sas for Ctrl+Alt+Del.
    codestringThe DOM's KeyboardEvent.code, such as KeyA, Enter, F4 or ControlLeft. Empty when you type a character with text.
    textstringOne character.
    scintegerPC set-1 scan code of the key's position, such as 16 for KeyQ. Only for keys whose character depends on the layout.
    mintegerHeld modifiers, on down only. Same bits as mouse.

    How the host picks the key

    1. By position, with sc. For letters, digits, punctuation and AltRight. The host presses that physical key and its own layout picks the character, so KeyQ types "a" on a French machine.
    2. By name, with code. For keys that mean the same on every layout: Enter, arrows, F-keys and modifiers.
    3. By character, with code: "" and text. 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 up releases that position, whatever the up says.
    • A line feed or tab in text is sent as the Enter or Tab key. Most Windows programs don't treat a raw line feed as Enter.
    • sas raises 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 vid to every key so the host can tell viewers apart. Don't send it yourself.
    Press EnterJSON
    → {"type":"key","action":"down","code":"Enter"}
    → {"type":"key","action":"up","code":"Enter"}
    Ctrl+CJSON
    → {"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 "é" on any layoutJSON
    → {"type":"key","action":"down","code":"","text":"é"}
    → {"type":"key","action":"up","code":"","text":"é"}
    Ctrl+Alt+DelJSON
    → {"type":"key","action":"sas"}
    Try it

    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.

    Or build one
    Presets
    Ctrl + C
    Messages
    
                

    Input

    Typing text

    • Built fromkey messages

    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: "" with text: a down, then an up.
    • Send line breaks and tabs as the Enter and Tab keys.
    • 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

    SpeedExactLike a person
    Slow310 ms205 ms
    Normal180 ms112 ms
    Fast120 ms72 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%).

    Type a string, evenly pacedJS
    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));
      }
    }
    Try it

    Text to keys

    Type any text and get the key messages that type it.

    Messages
    
                

    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.

    FieldTypeDescription
    textstringThe 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.

    Set it, then hear about a changeJSON
    → {"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.

    MessageRoutePurpose
    clip-offeryou → hostAdvertise files: token and files, a list of {name, size}. With clear: true, withdraw the offer.
    clip-pullhost → youThe machine is reading file index. Stream it from byte from.
    clip-pushyou → hostOne chunk: token, index, offset and base64 data. Mark the end with eof: true, or fail with error.
    clip-ackhost → youBytes received so far, in upto. Keep a window of unacknowledged chunks.
    clip-cancelhost → youStop 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.

    Offer a file and serve itJSON
    → {"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

    FieldTypeDescription
    reqIdintegerYour request number.
    monsinteger[]Screens to capture, such as [0], or [0,1] for a merged wall.
    lenintegerBytes per slice, from 4,096 to 1,048,576. Default 32,768.

    shot-get

    FieldTypeDescription
    reqIdintegerYour request number.
    idintegerThe picture id from the first reply.
    offsetintegerWhere the slice starts, in bytes.
    lenintegerBytes per slice, as above.

    Reply: fs-data

    FieldTypeDescription
    id, w, hintegerFirst slice only. Picture id, and its size in pixels.
    totalintegerSize of the whole PNG in bytes.
    offsetintegerWhere this slice starts.
    eofbooleantrue on the last slice.
    datastringThe slice, in base64.
    errorstringSent 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. len limits bytes on the wire only.
    • On a host running as a service, sign-in and UAC screens are captured too.
    Take a picture and pull itJSON
    // 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":"…"}
    When the picture is goneJSON
    ← {"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.

    FieldTypeDescription
    reqIdintegerYour request number.
    idintegerPicture id from shot.
    x, y, w, hintegerThe region, in the picture's pixels. Leave them out to read the whole picture.
    pw, phintegerThe picture's size. Always send them: a stale id then answers gone: 1 instead of reading a different picture.
    langstringA 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.

    Read a window's menu barJSON
    → {"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]]}]}}
    A picture that is no longer heldJSON
    ← {"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.

    1. Send shot with len: 4096. Keep id, w and h from the first reply and ignore its bytes. That costs about 5.5 KB.
    2. Send shot-ocr against that id for each region you care about. No new capture happens.
    3. 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.

    One picture, two questionsJSON
    → {"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.

    FieldTypeDescription
    onbooleantrue to drive, false to stop.
    • You rarely need it. Your first mouse or key turns it on.
    • If someone holds the machine exclusively, the relay answers control-revoked with their name in by.
    Take and give backJSON
    → {"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.

    FieldTypeDescription
    onbooleantrue 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.
    Hold, then releaseJSON
    → {"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.

    FieldTypeDescription
    onbooleanTurn Solo on or off.
    localbooleanBlock the keyboard, mouse, pen and touch at the machine. Default true.
    remotebooleanBlock every other operator, and other remote-control tools.
    keystringThe release chord, typed at the machine. Modifier names and a key joined by +. Default ctrl+alt+shift+f11.
    • To change local or remote, 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.
    Block the desk, then releaseJSON
    → {"type":"input-guard","on":true,"local":true,"remote":false,
       "key":"ctrl+alt+shift+f11"}
    → {"type":"input-guard","on":false}
    RefusedJSON
    ← {"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.

    FieldTypeDescription
    onbooleanBlank 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.
    Turn it on and offJSON
    → {"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.

    FieldTypeDescription
    on1 or 01 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.

    Arm, then disarmJSON
    → {"type":"key-route","on":1}
    ← {"type":"key-route-state","on":true,"vid":"v1k"}
    → {"type":"key-route","on":0}
    RefusedJSON
    ← {"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.

    FieldTypeDescription
    reqIdintegerYour request number.
    cmdstringThe command line.

    Reply: exec-result

    FieldTypeDescription
    codeintegerThe exit code. -1 when the command did not run.
    datastringEverything it printed, in base64.
    truncatedbooleantrue when the output passed 512 KB.
    errorstringWhy it did not run.
    • Windows runs it with cmd.exe /c. Call PowerShell yourself, as in powershell -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.
    Run it and read the outputJSON
    → {"type":"exec","reqId":12,"cmd":"hostname"}
    ← {"type":"exec-result","reqId":12,"code":0,
       "data":"V09SS1NUQVRJT04tMDcNCg=="}
    BusyJSON
    ← {"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.

    SendReplyWhat it does
    locklock-resultLocks the workstation. The reply has ok.
    keep-desktopnoneKeeps this session's desktop on the machine's own screen, unlocked.
    wol-checkwol-statusAsks whether this machine is set up to be woken over the network. The reply has enabled.
    wakewake-statusAsks 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.
    Wake a machine that is offJSON
    → {"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.

    1. Open /view. Don't sign in.
    2. Send join-begin with the relay's join key and the machine number.
    3. You receive a join-code. Everyone watching the machine sees a card with the same code, your name and your task.
    4. The first person to answer decides, within 2 minutes. Yes gives you auth-ok. No gives you join-nak with denied.
    5. select the machine and work within your scopes.
    6. Send join-leave when you are done.
    FieldTypeDescription
    keystringThe relay's join key, SM_JOIN_KEY. It gives the right to ask, nothing more.
    hoststringThe five-digit machine number.
    agentstringclaude, gpt, gemini or agent, shown as "Claude (agent)" and so on. Anything else shows as "AI (agent)".
    taskstringWhat you want to do, shown on the card. Up to 200 characters.
    control1Also ask for the keyboard and mouse.
    shell1Also ask to run commands.

    When a visit ends

    • After 60 minutes. The relay's SM_JOIN_MINUTES changes this, from 5 to 1440.
    • When an operator ends it.
    • When you send join-leave, which answers join-done. The other two end with access-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.

    A visit from start to finish/view
    → {"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.

    ScopeAsk withAllows
    Watchalwaysselect, unselect, shot, shot-get, shot-ocr, resync, active, idle, ping, pong, control-state, chat, chat-typing, chat-read, drive-status, spot, logout, join-leave
    Drivecontrol: 1mouse, key, control-lock
    Run commandsshell: 1exec

    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

    ReasonMeaning
    offThe relay has no join key set, so joining is off.
    noWrong key.
    no-hostNo such machine here, it is private, or it is offline. May carry relays, sibling relays to try next.
    nobodyNobody is watching the machine, so there is no one to ask.
    deniedA person said no.
    slow-downToo 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

    ReplyMeaning
    access-deniedwhy: "an agent may not do that". The message is outside your scopes.
    access-deniedwhy: "that visit has ended". Your time ran out.
    access-deniedwhy: "an operator ended this visit".
    Not on this relayJSON
    ← {"type":"join-nak","reason":"no-host",
       "relays":["wss://relay-2.example.com"]}
    Outside the grantJSON
    → {"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.

    GET/api/host/{id}/sysinfo

    Add ?fresh=1 to skip the cache.

    FieldDescription
    id, nameThe machine's number and name.
    platform, versionIts operating system family and host build.
    ip, ip_fromIts public address as the relay sees it, and how that was learned.
    lan_ipIts addresses on its own network.
    cached, age_ms, ttl_msWhether this came from the cache, how old it is, and how long it is kept.
    infoThe full machine card: OS, uptime, CPU, memory, disks, GPUs, screens, network adapters, local accounts, the administrators group, the board and the battery.

    Status codes

    StatuserrorMeaning
    200noneThe status.
    404no-such-hostNo machine with that number is online, or it is private.
    404disabledThe relay runs with SM_SYSINFO_PUBLIC=0.
    429rate-limitedMore than 20 requests a minute from your address.
    502gather-failedThe machine did not answer in time.
    502relay-unreachableThe 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.

    RequestShell
    RELAY=https://relay.example.com
    curl -s "$RELAY/api/host/48213/sysinfo"
    curl -s "$RELAY/api/host/48213/sysinfo?fresh=1"
    Response · 200JSON
    {
      "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": {…}
    }
    Try it

    Ask a relay

    Send a real request and see the answer.

    Not sent yet.
    GET

    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.

      Message
      
                  

        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.

        Examples

        Reference

        Errors

        Refusals arrive as ordinary messages. These are the ones to handle. The Reply decoder explains each of them.

        ReplyWhenWhat to do
        access-deniedYou may not send that, or your visit ended. why says which.Check your role or grant. An ended visit needs a new join.
        auth-badSign-in refused. retryMs means locked out for now.Check the credentials, or wait retryMs.
        auth-expiredThe saved tokens no longer work.Sign in with the password.
        control-revokedSomeone holds the machine exclusively. by names them.Wait, or ask them.
        join-nakAn agent's join was refused.See Refusals.
        fs-data + errorA screenshot or reading failed. With gone: 1, the picture is no longer held.Take a new shot.
        exec-result, code -1The command did not run. error says why.Wait if busy. Remote input must be on.
        key-route-stateon: false with a reason.Hover a window and arm again.
        input-guard, privacyon: false with a reason.Check your tier, or who holds the machine.
        wake-statusok: false with no-mac or no-lan-host.The relay knows no address for it, or no machine on its network is online.
        No replyInput on a view-only machine, a relay-only type, or a wrong control-socket key.Take a shot and look.

        Reference

        Limits

        WhatLimit
        Access token60 minutes
        Control socket attach10 seconds after connecting
        Join code2 minutes
        Agent visit60 minutes (5 to 1,440)
        Screenshot slice4 KB to 1 MB, default 32 KB
        Pictures heldnewest 3, 64 MB, 10 minutes
        Screenshot and reading queue4 jobs
        Wheel step120 per notch, ±1,200 per message
        Clipboard text1 MB
        Command10 seconds, 512 KB of output
        Co-driving window5 seconds
        Machine status cache60 seconds
        Machine status rate20 a minute per address

        Reference

        Message index

        Every message type a client works with, and what a view-only machine does with it.

        TypeRouteView-only machinePurpose
        Session
        auth, auth-codeclient → relayn/aSign in
        select, unselectclient → relayn/aOpen or leave a machine
        ctl-attachclient → relayn/aAttach the control socket
        ping, pongbothn/aLiveness
        Input
        mouseclient → hostDroppedPointer
        keyclient → hostDroppedKeys, and Ctrl+Alt+Del
        clipboardbothDroppedClipboard text
        file-*client → hostDroppedUploads
        clip-*bothAnsweredClipboard files
        key-routeclient → hostRefusedRoute your typing to one window
        Screen
        shot, shot-getclient → hostWorksScreenshot
        shot-ocrclient → hostWorksText recognition
        monitor, monitor-mergeclient → hostWorksScreen choice and merged wall
        virtual-display, display-modeclient → hostWorksVirtual screens
        quality, codec, cursor-modeclient → hostWorksStream settings
        resyncclient → relayWorksAsk for a fresh full frame
        Control
        control-stateclient → relayn/aSay you are driving
        control-lockclient → relayn/aExclusive control
        input-guardclient → relay → hostRefusedSolo
        privacyclient → relay → hostWorksPrivacy screen
        Machine
        execclient → hostRefusedRun one command
        lock, keep-desktopclient → hostWorksWorkstation lock and desktop
        wol-checkclient → hostWorksCan this machine be woken?
        wakeclient → relayn/aWake an offline machine
        fs-*, term-*client → hostAnsweredFile manager and terminal
        Agents
        join-beginagent → relayn/aAsk to visit a machine
        join-leaveagent → relayn/aEnd the visit
        join-allow, join-deny, join-revokeclient → relayn/aA person answers or ends a visit
        Relay only. A client frame that contains these is dropped.
        stream-start, stream-stop, keyframe, abr, udp-inforelay → hostn/aStream control
        input-releaserelay → hostn/aLet go of held input
        wake-send, presence-alerts, monitors, permonrelay → hostn/aSent on a viewer's or administrator's behalf
        update-*, host-challengerelay → hostn/aHost updates and host sign-in