# Loopback relay (0.7.0) Loopback is a remote-control harness. Three parts, all over WebSocket: - relay: this server; only routes messages, holds no state on disk. - agent: runs on a target machine (e.g. a Windows kiosk); dials this relay and registers. - mcp bridge: an MCP server on the operator's machine; connects here, lists agents, attaches to one, drives it. Relay WebSocket URL: wss://loopback-next.elevated.app Health: https://loopback-next.elevated.app/healthz Auth: AirOps. Agents present a device token (minted from the device's fingerprint + provisioning token); operators sign in once with `loopback-mcp login` (or the remote_login tool) using their AirOps account, which needs the loopback.operate permission. ## Run an agent on a target machine Download an agent binary from https://loopback-next.elevated.app/download and run: loopback-agent --relay wss://loopback-next.elevated.app --fingerprint --provisioning-token --root ## Set up the MCP bridge (operator machine, e.g. Claude Code) The bridge is launched over stdio by your MCP client with NO arguments and is configured through environment variables (all optional): LOOPBACK_RELAY_URL = wss://loopback-next.elevated.app (this is also the built-in default) LOOPBACK_AIROPS_ISSUER = https://core-api.elevated.app (default; QA: https://core-api-qa.elevated.app) LOOPBACK_CLIENT = (auto-attach to one agent) Sign in once per machine: run `loopback-mcp login`, open the printed URL and confirm the code with your AirOps account. Credentials are kept in the macOS Keychain (elsewhere in ~/.config/loopback/credentials.json); `loopback-mcp whoami` / `loopback-mcp logout` manage them. Inside an MCP session, the remote_login tool starts the same flow. Step 1 - download the bridge for the operator's OS: Step 2 - register it with Claude Code (one command): claude mcp add loopback -- /loopback-mcp (on Windows use the full path to loopback-mcp.exe) ...or add it to a project .mcp.json: { "mcpServers": { "loopback": { "command": "/loopback-mcp", "env": { "LOOPBACK_RELAY_URL": "wss://loopback-next.elevated.app" } } } } Step 3 - use it. The bridge exposes these tools; typical flow: 1. remote_list_hosts -> see which kiosks are online 2. remote_use -> attach to one (auto-attaches if only one, or if LOOPBACK_CLIENT set) 3. remote_screenshot -> see the screen; remote_run / remote_read_file -> inspect; remote_click/type/key -> fix Signing notes: - macOS binaries are ad-hoc signed, so they launch on Apple Silicon; a downloaded copy is still quarantined, so run `xattr -d com.apple.quarantine ` once (shown above). - Windows binaries are not code-signed; SmartScreen may warn on first interactive launch (Unblock-File the download, or deploy via the scheduled task, which does not prompt). The relay default is already this URL, so signing in is usually the only setup needed. ## Driving the bridge from a script (no MCP client) Only needed when you spawn loopback-mcp yourself over stdio instead of using an MCP client (Claude Code etc. manage the process for you). Verified behavior: - Closing stdin does NOT stop the bridge: it holds the relay socket open, so `echo ... | loopback-mcp` never returns. (It exits on EOF only if it never connected, e.g. not signed in; do not rely on that.) - Stop it with SIGTERM (`kill `). Do NOT use `kill -INT` from a script: non-interactive shells start background jobs/coprocs with SIGINT ignored, so the bridge keeps running and `wait` hangs. - Signal the bridge's own PID. In bash use `coproc MCP { exec loopback-mcp; }`; without `exec`, $MCP_PID is a subshell and killing it leaves the bridge orphaned. - Order: send initialize, READ its response, send notifications/initialized, then tools/call. stdout is newline-delimited JSON-RPC, one message per line. Match responses by id; skip lines with no id. Never pace requests with sleep. stderr is silent unless LOOPBACK_DEBUG=1. - remote_use state lives in the bridge process. A fresh process is unattached; with several agents online a tool call returns EAMBIGUOUS. For one-shot calls set LOOPBACK_CLIENT=. - Tool failures are normal results with "isError": true and text `Error: : ` (e.g. ENOCLIENT, EAMBIGUOUS, EACCES, ETIMEDOUT), not JSON-RPC errors. - remote_run waits up to timeoutMs (default 120s, max 30 min); make your read timeout longer. One-shot bash helper (bash 4+; on macOS, which ships bash 3.2, use Python subprocess instead): # One-shot call: start the bridge, call ONE tool, print its result line, stop the bridge. # Usage: mcp_call '' mcp_call() { local line rc=1 coproc MCP { exec loopback-mcp; } printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"script","version":"1"}}}' >&"${MCP[1]}" read -r -t 30 line <&"${MCP[0]}" || { echo "bridge did not initialize" >&2; kill "$MCP_PID"; return 1; } printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' >&"${MCP[1]}" printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"%s","arguments":%s}}\n' "$1" "${2:-{\}}" >&"${MCP[1]}" while read -r -t "${MCP_TIMEOUT:-300}" line <&"${MCP[0]}"; do case $line in *'"id":2'*) printf '%s\n' "$line"; rc=0; break ;; esac done kill "$MCP_PID" 2>/dev/null; wait "$MCP_PID" 2>/dev/null return $rc } # example: LOOPBACK_CLIENT=kiosk-01 mcp_call remote_run '{"command":"uname -a"}' Do not write to ${MCP[1]} from a pipeline or $(...): coproc fds are not visible there (Bad file descriptor). For several calls, keep ONE bridge process open (Python: subprocess.Popen with stdin/stdout pipes, read stdout line by line matching the request id), then terminate() it when done. On kiosks the Elevation launcher runs the agent and hands it the device fingerprint and provisioning token, so it needs no secret in its config. Its log rotates so it cannot fill the drive. On locked-down networks the socket resets periodically; the agent auto-reconnects. Windows kiosk notes (ANY multi-desktop kiosk, vendor-neutral): - remote_screenshot and remote_click/type/key act on the current INPUT desktop via a compiled native helper, so they reach a passenger UI running on its own Win32 desktop even though the agent is on Default. Pass `desktop` to target a named desktop. - A long-lived service that would inherit the call's stdio (e.g. a platform launcher .bat) must be started with remote_run background:true, or the call hangs. - HOW you make a given desktop the input desktop is platform-specific (see below). ## Platform-specific: Elevation CUSS2 (ElevatedAI) ONLY — activating an airline IMPORTANT: everything in THIS section is EXCLUSIVE to Elevation's CUSS2 platform (a.k.a. ElevatedAI / c2-platform). Other CUSS vendors' platforms use different activation APIs, desktop layouts, tenant ids and scripts. Do NOT assume any of the below on a non-Elevation kiosk: first identify which platform is installed/running, then use that platform's own mechanism (its docs, config, or local API). On an Elevation CUSS2 kiosk (c2-platform in C:\cuss2): each airline (a "tenant") runs on its own Windows desktop; the common-use launch screen is the CLA desktop. Only the ACTIVE tenant's desktop is the input desktop, so remote_screenshot and remote_click/type/key reach an airline app ONLY after that airline is made ACTIVE. A FRESH kiosk has ONLY the CUSS2 platform (in C:\cuss2) and its local control API — there are NO helper scripts in C:\kiosk-app or anywhere else by default. Do NOT assume any .ps1 helper exists. Drive the platform through its control API; if you want a helper, WRITE it to the kiosk yourself with remote_write_file, then run it with remote_run (shell=powershell). Control API: ws://[::1]:22222/cla (IPv6 loopback ONLY — 127.0.0.1 does NOT connect). JSON text frames. The platform also sends periodic {"type":"ping"} frames — ignore them. Flow: -> {"type":"hello","client":"...","version":"1"} <- {"type":"hello_ack","airlines":[{"id":"FTT:FFT","tenantId":"FTT","state":"AVAILABLE","desktop":"..."},...]} (this is how you LIST airlines + live state; there is no separate list call) Launch/activate an airline (NO auth): -> {"type":"launch","airlineId":"","lang":"en"} Restart a stuck airline (auth REQUIRED): -> {"type":"tester_auth","requestId":"a1","pin":""} then after <- {"type":"tester_auth","status":"OK"}: -> {"type":"tester_restart_tenant","requestId":"r1","tenantId":""} The tester PIN is deliberately NOT in this doc — get it from the relay operator and pass it at runtime; never hardcode it into a file left on the kiosk. Windows PowerShell speaks this API via System.Net.WebSockets. On a fresh kiosk, write this helper once with remote_write_file (e.g. to C:\cuss2\cla-control.ps1), then invoke it: --- cla-control.ps1 --- param([ValidateSet('list','launch','restart')][string]$Action='list',[string]$TenantId,[string]$Pin,[string]$Url='ws://[::1]:22222/cla') $ErrorActionPreference='Stop' $ws=New-Object System.Net.WebSockets.ClientWebSocket;$cts=New-Object System.Threading.CancellationTokenSource function Send($o){$j=$o|ConvertTo-Json -Compress;$b=[Text.Encoding]::UTF8.GetBytes($j);$ws.SendAsync([ArraySegment[byte]]::new($b),'Text',$true,$cts.Token).Wait()} function Recv($ms){$buf=New-Object byte[] 65536;$sb=New-Object Text.StringBuilder do{$t=$ws.ReceiveAsync([ArraySegment[byte]]::new($buf),$cts.Token);if(-not $t.Wait($ms)){return $null};$r=$t.Result;if($r.MessageType -eq 'Close'){return $null};[void]$sb.Append([Text.Encoding]::UTF8.GetString($buf,0,$r.Count))}while(-not $r.EndOfMessage) $sb.ToString()} $ws.ConnectAsync([Uri]$Url,$cts.Token).Wait() Send @{type='hello';client='cla-control';version='1'} $ack=$null;$d=(Get-Date).AddSeconds(5) while((Get-Date) -lt $d){$m=Recv 1000;if($m){$o=$m|ConvertFrom-Json;if($o.type -eq 'hello_ack'){$ack=$o;break}}} if(-not $ack){Write-Host 'NO_HELLO_ACK (platform stopped? start it first)';exit 1} if($Action -eq 'list'){$ack.airlines|ForEach-Object{Write-Host ("{0} tenant={1} state={2} desktop={3}" -f $_.id,$_.tenantId,$_.state,$_.desktop)};exit 0} $t=$ack.airlines|Where-Object{$_.tenantId -eq $TenantId}|Select-Object -First 1 if(-not $t){Write-Host "UNKNOWN_TENANT $TenantId";exit 2} if($Action -eq 'launch'){Send @{type='launch';airlineId=$t.id;lang='en'}} elseif($Action -eq 'restart'){ Send @{type='tester_auth';requestId='a1';pin=$Pin};$ok=$false;$d=(Get-Date).AddSeconds(5) while((Get-Date) -lt $d){$m=Recv 1000;if(-not $m){continue};if($m -match '"type":"ping"'){continue};$o=$m|ConvertFrom-Json;if($o.type -eq 'tester_auth'){$ok=($o.status -eq 'OK');break}} if(-not $ok){Write-Host 'AUTH_FAILED (bad PIN?)';exit 3};Send @{type='tester_restart_tenant';requestId='r1';tenantId=$TenantId}} $d=(Get-Date).AddSeconds(18);while((Get-Date) -lt $d){$m=Recv 1000;if($m -and ($m -notmatch '"type":"ping"')){Write-Host "<< $m"}} try{$ws.CloseAsync('NormalClosure','bye',$cts.Token).Wait(2000)|Out-Null}catch{} Write-Host 'DONE' --- end cla-control.ps1 --- List: powershell -ExecutionPolicy Bypass -File C:\cuss2\cla-control.ps1 -Action list Launch: powershell -ExecutionPolicy Bypass -File C:\cuss2\cla-control.ps1 -Action launch -TenantId FTT Restart: powershell -ExecutionPolicy Bypass -File C:\cuss2\cla-control.ps1 -Action restart -TenantId FTT -Pin After launching an airline its desktop becomes the input desktop, so remote_screenshot / remote_click / remote_type then act on that airline (kiosk screen is 1280x1024; coordinates are screen pixels). Exit codes: 0 ok, 1 platform not reachable, 2 unknown tenant, 3 bad PIN. Start / stop the whole platform (these .bat files DO ship with the platform, in C:\cuss2): - START (do it EXACTLY like this): remote_run with command = C:\cuss2\startCUSS.bat cwd = C:\cuss2 <-- REQUIRED, see below background = true <-- REQUIRED, see below Why cwd MUST be C:\cuss2: startCUSS.bat just runs `start c2-platform-windows-x64.exe` with NO `cd`, so the platform inherits the caller's working directory and resolves its config + cache relative to it. Launched from anywhere else (e.g. the loopback agent's own folder like C:\cuss2\loopback) the platform DOES start but reads the WRONG config/cache and misbehaves — this is the #1 reason agents fail to bring the platform up. Forcing cwd=C:\cuss2 fixes it. Why background:true: a foreground remote_run of startCUSS.bat never returns and HANGS the call. (If the agent is path-jailed, C:\cuss2 must be inside its --root or the cwd is rejected — run the agent with --root C:\cuss2.) Ready in ~10-25s; then -Action list shows the tenants AVAILABLE. - STOP (graceful, returns the screen to the Default desktop): remote_run command C:\cuss2\stopCUSS.bat (cwd C:\cuss2 is safest here too). If -Action list returns NO_HELLO_ACK the platform is stopped — start it first. Airline (tenant) state machine: STOPPED -> INITIALIZE -> UNAVAILABLE -> AVAILABLE -> ACTIVE. - launch works ONLY from AVAILABLE; launching a STOPPED/UNAVAILABLE tenant returns WRONG_APPLICATION_STATE. A tenant becomes AVAILABLE only once its airline web app (Chrome, served by the airline backend) has started and registered. -Action list shows each state. - tester_restart_tenant only relaunches a tenant the platform is already managing; for a STOPPED/never-launched tenant it returns OK but is a no-op and cannot revive an app whose backend never connected — reload the whole platform (stopCUSS.bat then startCUSS.bat) or fix the backend instead. ## Tools the bridge exposes remote_login (sign in with AirOps), remote_list_hosts, remote_use, remote_info, remote_read_file, remote_write_file (append:true to append), remote_edit_file, remote_list_dir, remote_stat, remote_mkdir, remote_remove, remote_move, remote_glob, remote_grep, remote_run (bash/sh/zsh/cmd/powershell/pwsh; background:true = launch detached, return now), remote_screenshot (PNG/JPEG; Windows captures the current input desktop; optional desktop=), remote_download (stream large files/logs to the operator), remote_click, remote_type, remote_key (drive the UI; Windows injects on the input desktop; optional desktop=). Connection: the agent and bridge keep the link alive automatically and reconnect in under 2s if it drops, so a brief reconnect in the logs is normal, not an error. ## Downloads (no binaries are currently hosted)