Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rdc

rdc lets a coding agent on one machine see and operate the desktop of another over your Tailscale network: screenshots, mouse, keyboard, window focus and clipboard. It works with macOS, Linux and Windows targets and plugs into Claude Code, or any MCP client, as a set of tools.

A typical use: an agent is fixing something on a headless Mac mini and reaches a dialog that needs a click. It takes a screenshot, clicks the button and continues.

  • No passwords, tokens or certificates. rdc serve listens only on the machine’s Tailscale address and asks the local tailscaled who each caller is. You allow tailnet logins, device names or tags, and can limit each to view, input or clipboard.
  • No shell. rdc is a screen-and-input surface. Use SSH for commands.
  • One binary. The same executable is the daemon, the CLI and the MCP server.
  • Every request and rejection is written to an audit log.

Where to start

If you want to…Read
understand what happens when an agent clicksHow rdc works
install itInstall, then the guide for your platform
decide who may connect and what they may doGrants and Tailscale policy
wire it into Claude CodeMCP tools
fix somethingTroubleshooting

Status

Target platformState
Linux, Wayland (Hyprland / wlroots)verified
Linux, Wayland (GNOME, KDE)screenshots only; input not wired up yet
Linux, X11compiles, untested
macOS 15+, Apple siliconverified
Windows 11verified on one display; multi-monitor implemented, untested

Releases: github.com/bscott/rdc/releases. Binaries are not code-signed; see Install.

rdc is free software under the GNU GPL-3.0-or-later.

How rdc works

A coding agent on your machine takes a screenshot of another machine, decides where to click, and clicks. This page follows that click through each step, shows who is allowed to send it, and explains how a pixel in the screenshot maps to the right place on a screen with a different resolution.

Two roles, one binary

The same rdc executable runs on both ends. On the machine being controlled it is a daemon, rdc serve. On your machine it is either an MCP server that Claude Code talks to, rdc mcp, or a command-line client you use directly. The network between them is your Tailscale tailnet.

your machine Claude Code (the agent) rdc mcp pixels → points MCP, stdio HTTP · JSON · PNG inside the Tailscale tunnel (WireGuard) machine being controlled rdc serve auth · audit the desktop screen · mouse · keys OS APIs tailscaled whois?
One connection crosses between the machines, and it carries no password. The Tailscale tunnel identifies the sending device, and the daemon asks its local tailscaled which user or tags that device has.
Your machineMachine being controlled
Commandrdc mcp --target studio-mac or rdc -t studio-mac …rdc serve
JobSpeaks MCP to the agent, converts screenshot pixels to desktop pointsIdentifies callers, captures the screen, sends input
Runs asA process the agent startsLaunchAgent (macOS), systemd user service (Linux), scheduled task (Windows)
Holds secretsNoNo

A click, end to end

agent rdc mcp rdc serve 1 · screenshot() 2 · GET /v1/screenshot 3 · PNG 2560×1440 + rect header 4 · image 1568×882 · "covers x=0 y=0 w=2560 h=1440" 5 · click(x=780, y=480) ← pixels in that image 6 · POST /v1/act click 1273,784 ← desktop points 7 · {ok} after validate → move → press → release 8 · GET /v1/screenshot (350 ms later) 9 · fresh image so the agent can check its work 780 × 2560/1568 = 1273
The agent gives coordinates as pixels of the last screenshot. rdc mcp stores the desktop region that screenshot covered and converts, so the model does not need to know about display scaling or multiple monitors.

The daemon returns the full-resolution capture and the client downscales it, so the image size sent to the agent can be changed without touching the remote machine. Every action returns a new screenshot by default, which keeps the mapping current and shows the agent the result.

Who gets in

rdc has no passwords, tokens or certificates. The Tailscale tunnel identifies the sending device, tailscaled reports who that device is, and a list in the config says what they may do. Every request goes through these checks in order.

0 · at startup: bind the Tailscale IP only anything else is refused, except 127.0.0.1 with --dev-loopback 1 · Host header names this machine? its Tailscale IPs, MagicDNS name, hostname, [serve].hosts 2 · peer IP is in 100.64/10 or fd7a:115c:a1e0::/48? taken from the TCP socket, never from a header 3 · ask tailscaled: whois(peer IP) → login alice@example.com node studio-laptop tags [] tagged devices: the creator's login is dropped; only tags and node name count 4 · match grants, union their capabilities "alice@example.com" → all { who = "monitor-bot", can = "view" } cached 30 s per IP 5 · does this route's capability match? screenshot needs view · click needs input · clipboard needs clipboard 6 · do it, then write the audit line 421 misdirected request 403 not a Tailscale address 403 not a tailnet peer 403 not in the allowlist 403 forbidden: may not use `input` audit.jsonl every outcome, one line
Every rejection and every success is written to the same audit file. Tailscale reports the user who created a tagged device; rdc drops that login, so tagged devices match only by tag or node name.

Grants live in the config file. A plain string grants everything; an inline table limits it. Several matching grants add up.

[serve]
allow = [
  "alice@example.com",                                     # full control
  { who = "monitor-bot", can = "view" },                   # screenshots only
  { who = ["tag:ops", "bob@example.com"], can = ["view", "clipboard"] },
]

Capabilities: view (displays, windows, screenshots), input (mouse, keyboard, focus), clipboard (read and write). See Grants and Tailscale policy for worked examples and the matching Tailscale rules.

Where the click lands

Machines report coordinates differently. A laptop with a 2880×1920 panel at 2× scale has a 1440×960 point desktop. A Mac mini at 2560×1440 is 1×. A Windows laptop at 2560×1600 and 150 % reports physical pixels. rdc uses one rule for all of them: every coordinate on the wire is a logical desktop point, a position in the virtual desktop spanning all monitors in the units the OS uses to place windows. A screenshot carries the rectangle of points it covers.

screenshot 1568×1045 pixel (784, 522) what the agent sees × 1440/1568 desktop 1440×960 points point (720, 480) what goes over the wire Wayland · fraction of first output's mode 720/1440 × 2880 = 1440, 480/960 × 1920 = 960 X11 · multiply by Xft.dpi scale 720 × 2 = 1440, 480 × 2 = 960 macOS · points already 720, 480 Windows · physical px already SendInput over the whole virtual desktop
One conversion on the client, then one per backend on the daemon. The numbers are the laptop's; on the Windows machine the same arithmetic maps a 1400-pixel-wide image onto its 2560×1600 screen.

Before moving anything, the daemon checks that the point is inside the combined display area, limits scrolling to 100 wheel steps, and rejects keys the platform does not have. A drag always releases the button, even if a move in the middle fails.

Per platform

The daemon uses different operating-system APIs on each platform. The table lists what it uses and the platform behaviour that determined how it is installed.

PlatformCaptureInputWindows and focusRuns asWhat shaped it
Linux, Waylandportal Screenshot, then wlr-screencopywlr virtual pointer and keyboardhyprctl on Hyprlandsystemd user unitGNOME and KDE lack the wlr input protocols: capture works there, input does not yet
Linux, X11xcbXTESTwindow list onlysystemd user unitxcap reports geometry divided by DPI scale; input wants raw pixels, so rdc multiplies back
macOSscreencapture (about 0.3 s); CoreGraphics fallback is slow on recent macOSCGEvent via enigoxcap list, NSRunningApplicationLaunchAgent inside a signed rdc.appScreen Recording and Accessibility grants are keyed to the code signature; unsigned builds lose them on every rebuild
WindowsGDI / Graphics CaptureSendInput normalised over the virtual desktopxcap list, SetForegroundWindowTask Scheduler logon task at standard integrity (--elevated opts into the highest run level)A service or SSH session is session 0 with no display; a non-elevated daemon cannot send input to elevated windows, which is the documented trade-off

What gets recorded

One JSON object per request or rejection, appended to audit.jsonl in the platform state directory, mode 0600, rotated by size. Typed text is recorded as a character count only. Key chords, window selectors and error messages are recorded with control characters replaced.

{"ts":"2026-09-09T16:08:55.979Z","peer":"100.64.0.7","login":"alice@example.com","node":"laptop",
 "method":"POST","path":"/v1/act","action":"input.click 100,100 Left x1",
 "outcome":"denied","status":403,"detail":"alice@example.com may not use `input` on this machine","ms":0}

Read it on the daemon machine with rdc audit -n 50, or --json for the raw lines.

Security notes

  • Anyone with an input grant controls the keyboard. A desktop session is enough to open a shell, so rdc does not offer one separately. Grant view broadly and input narrowly.
  • The tailnet is the security boundary. A stolen device that is on the allowlist, or a Tailscale policy that lets the wrong nodes reach port 7770, gives access to the desktop.
  • The Host check protects against browsers. A web page on an allowed machine could point its hostname at the daemon’s address and use that machine’s identity. The daemon answers 421 to any Host that is not one of its own names.
  • The agent never handles a password or a tailnet key. It receives images and returns pixel coordinates.
  • macOS asks again for Screen Recording about once a month. Until someone approves the prompt, screenshots show only the wallpaper. rdc doctor reports the missing permission.

Full threat model: Security.

Architecture

One Rust crate, one binary, three roles chosen by subcommand.

                  ┌──────────────── rdc (binary) ────────────────┐
  agent ── MCP ──►│ mcp.rs ── view.rs ──┐                        │
  human ── CLI ──►│ main.rs ────────────┼──► dyn Desktop         │
                  │                     │      ├─ local/  (xcap, enigo, arboard, hyprctl/AppKit)
                  │                     │      └─ remote.rs (HTTP client) ──► another rdc `serve`
                  │ serve: server/ (axum) ── auth.rs (tailscale whois) ── local Desktop
                  └───────────────────────────────────────────────┘

The Desktop trait

src/desktop/mod.rs defines everything rdc can do to a machine:

#![allow(unused)]
fn main() {
displays()      -> Vec<Display>          // id, name, logical rect, scale, primary
screenshot(req) -> Screenshot            // encoded image + the desktop rect it covers
windows()       -> Vec<Window>           // id, pid, app, title, rect, focused, minimized
focus(target)                            // by id / app substring / title substring
input(action)                            // move, click, button, drag, scroll, type, key
clipboard_get() / clipboard_set(text)
}

Two implementations: desktop/local does the work in-process; desktop/remote forwards over HTTP to a daemon. The CLI and the MCP server only ever hold an Arc<dyn Desktop>, so --target local and --target somehost are the same code.

Coordinates

Server side, everything is logical desktop points: the virtual desktop spanning all monitors, as the OS reports it (on a 2× display a 2880×1920 panel is 1440×960 points). A Screenshot carries the rect it covers so clients can map pixels back to points regardless of scaling or downsampling. view.rs does that mapping for the MCP layer.

Under Wayland, enigo’s absolute pointer move takes a fraction of the first output’s physical mode. desktop/local/input.rs converts points → fraction of the whole layout → that extent, which is why clicks land correctly on scaled displays.

Input and clipboard threads

The enigo handle is owned by one dedicated OS thread and the arboard handle by another, each fed over a channel, because neither is happily shared across threads on every platform. Keeping them apart means a clipboard owner that never answers (Wayland transfers have no deadline) cannot block mouse and keyboard; the caller also gives up on clipboard operations after 5 seconds.

Wire API

Plain HTTP + JSON on the daemon, all routes behind the auth middleware. Base path /v1.

RoutePurpose
GET /statedisplays and windows
GET /screenshot?display=all|primary|ID&format=png|jpg&max=Nimage bytes; headers x-rdc-rect: x,y,w,h and x-rdc-size: W,H
POST /actJSON {"kind":"input", "type":"click", …} / {"kind":"focus","by":"app","value":"…"} / {"kind":"clipboard_set","text":"…"}
GET /clipboard{"text": …}
GET /whoamithe caller’s resolved identity
GET /healthok (also authenticated)

Errors are JSON {"code": "...", "message": "..."} with codes not_found, unsupported, permission, unauthorized, bad_request, backend and matching HTTP statuses. The wire types live in src/proto.rs and are shared by both sides.

Authentication flow

  1. serve resolves the bind address: --bind, config, or the node’s Tailscale IPv4 from the LocalAPI. Non-Tailscale addresses are refused (except --dev-loopback).
  2. For every request the middleware first checks the Host header against the node’s own IPs, MagicDNS name, hostname and [serve].hosts; anything else is 421 Misdirected Request.
  3. It then takes the peer IP from the socket. Loopback → refused, unless --dev-loopback. Non-Tailscale range → refused.
  4. tailscale.rs calls GET /localapi/v0/whois?addr=IP on tailscaled (unix socket on Linux, loopback TCP + proof token for the macOS GUI variants, named pipe on Windows, or the tailscale whois --json CLI as fallback). The response gives login, node name and tags.
  5. Tagged nodes have their creator’s login stripped; they are identified by tags and node name only. The identity is matched against the grants and cached for 30 s; the union of the matching grants’ capabilities (view, input, clipboard) travels with the identity.
  6. Each desktop route requires one capability: state and screenshot need view, /act needs input (or clipboard for clipboard writes), /clipboard needs clipboard. Missing capability → 403 forbidden. /v1/whoami and /health need only a valid identity.
  7. Every request and rejection is appended to the audit log (src/server/audit.rs) as a JSON line with identity, action summary, outcome and duration. Rejections are recorded by the middleware; authorized requests by the route handlers, after they know the outcome.

Input requests are validated before they touch the desktop: coordinates must lie inside the union of the displays, scroll magnitudes are capped at 100 steps, and unsupported keys are refused before any modifier is pressed. A drag always releases the button even if a move fails.

Per-platform pieces

CaptureInputWindows / focusService
Linux Waylandxcap: portal Screenshot → wlr-screencopyenigo waylandhyprctl on Hyprland; xcap list elsewheresystemd –user
Linux X11xcap (xcb)enigo x11rbxcap listsystemd –user
macOSscreencapture CLI (fast), xcap CoreGraphics fallbackenigo (CGEvent)xcap list, NSRunningApplication.activateLaunchAgent, signed .app
Windowsxcap (GDI/WGC)SendInput over the virtual desktopxcap list, SetForegroundWindowelevated Task Scheduler logon task

MCP layer

mcp.rs uses the official rmcp SDK. Each tool acquires one mutex so calls are serialized, looks up the current ViewMap (set by the last screenshot, or taken on demand), converts image pixels to desktop points, performs the action, waits ~350 ms and returns a new screenshot unless asked not to. Images are returned as base64 PNG content blocks.

Security

What rdc is

rdc serve gives whoever it trusts full control of a logged-in desktop session: they can see the screen, move the mouse, type, press shortcuts, focus windows and read or write the clipboard. Treat it exactly like handing someone the keyboard. There is deliberately no shell tool, but a desktop session is more than enough to open one.

Threat model

Trusted: your Tailscale tailnet, your tailnet identity provider, the machine running rdc serve, and every identity in [serve].allow.

How access is decided. The daemon binds only to the machine’s Tailscale IP (it refuses any other non-loopback address). For every request it takes the peer IP from the TCP connection, asks the local tailscaled who that IP is (whois), and compares the login name, node name and tags against the allowlist. Results are cached for 30 seconds. There are no passwords, tokens or TLS: the tailnet’s WireGuard layer provides encryption and the identity.

Consequences.

  • Anyone whose login, node or tag matches a grant gets that grant’s capabilities: view (screenshots, windows), input (mouse, keyboard, focus), clipboard, or all. A plain identity string grants all three. "*" allows the entire tailnet. Tags match every node carrying them. Tagged devices are identified by their tags and node name only; the login of the user who created them carries no authority.
  • If an allowed identity is compromised (stolen device, leaked auth key, shared tailnet), the attacker has your desktop. Tailscale ACLs are your second layer: restrict which nodes may reach the rdc port at all.
  • whois is only as accurate as tailscaled. If the local daemon is unavailable, rdc falls back to the tailscale CLI; if neither works, every request is rejected.
  • The config file is the allowlist. On Unix the daemon refuses to start if config.toml or its directory is owned by someone else or writable by group/others, since editing it is equivalent to desktop access; RDC_INSECURE_CONFIG=1 overrides with a warning.
  • --dev-loopback binds 127.0.0.1 and disables authentication for loopback connections. It is for local development and must never be used on a shared machine or forwarded.
  • MCP clients talk to rdc mcp over stdio on the operator’s machine. The operator’s agent inherits the operator’s tailnet identity; anything the agent does is done as you.
  • The macOS build needs Screen Recording and Accessibility permissions. Grant them only to a signed bundle you built or verified; see scripts/macos.

Audit. Every request and rejection is appended to a JSON-lines audit log (mode 0600, size rotated) with the caller’s identity, an action summary, the outcome and timing. Typed text is never logged, only its length. Read it with rdc audit.

Not yet implemented (tracked as issues): rate limiting, and a pause when a human is physically using the input devices.

Reporting a vulnerability

Please do not open a public issue for security problems. Use GitHub’s private vulnerability reporting on this repository (“Report a vulnerability” under the Security tab). You should get a response within a week. Fixes will be released as a new tagged version with a note in the release description.

Supported versions

Only the latest tagged release receives fixes.

Design and security principles

This file is for contributors and for coding agents that help them. It says what rdc is, what it must never become, and the checks every change has to pass. CLAUDE.md points here.

What rdc is

One Rust binary. rdc serve runs on a machine and lets identified callers on the same Tailscale tailnet take screenshots and drive mouse, keyboard, window focus and clipboard. rdc mcp and the CLI are clients. The MCP layer converts pixels in the last screenshot to desktop points.

Design principles

  1. One seam. New capabilities go into the Desktop trait (src/desktop/mod.rs) first, then the wire API (src/proto.rs, src/server/routes.rs), then the CLI and MCP tools. All three surfaces stay equivalent; nothing is reachable from one that isn’t from the others.
  2. Coordinates are logical desktop points on the wire. Screenshots carry the rect they cover. Platform-specific conversion happens only in src/desktop/local/input.rs and the platform module. Never let a backend’s native units leak into proto.
  3. Platform code lives behind cfg(target_os) in its own module. The common path must compile and pass clippy on Linux, macOS and Windows. Run cargo clippy --target x86_64-pc-windows-gnu --all-targets -- -D warnings and the aarch64-apple-darwin equivalent from Linux before pushing.
  4. Fail closed. Config typos are errors. Missing identity is a rejection. Unknown capability names are errors. Prefer returning RdcError to panicking anywhere network input can reach.
  5. No shell, no file transfer, no new listeners. rdc is a screen-and-input surface. SSH exists for everything else.
  6. Documentation is part of the change. Update docs/, skills/rdc/SKILL.md when it affects agents, and add a line under Unreleased in CHANGELOG.md.

Security principles

  1. Identity comes from tailscaled, never from the request. The peer IP is taken from the socket; whois resolves it. Headers, bodies and query strings carry no identity.
  2. Tagged devices are their tags. Tailscale reports the creating user for tagged nodes; rdc discards that login. Do not reintroduce it.
  3. The Host header must name this machine. This blocks DNS rebinding from a browser on an allowed node. Keep the check before authorization.
  4. Capabilities gate every desktop route. view, input, clipboard. Adding a route means choosing its capability and adding an audit call.
  5. Everything is audited. One JSON line per request or rejection with identity, an action summary, outcome and status. Never log typed text or clipboard contents; record their length. Sanitize any value that came from a client before it reaches the log or a terminal.
  6. Validate before side effects. Coordinates inside the display union, bounded scroll, supported keys, both ends of a drag checked before the button goes down. Release what you pressed even on error.
  7. Bind only Tailscale addresses. --dev-loopback is the single exception and stays loopback-only and unauthenticated by name.
  8. Least privilege for helpers. Anything that stops or kills processes must match the exact binary path, command line and user, and must never touch the current process or other rdc clients.
  9. Secrets never enter the tree. No tokens, keys or hostnames of real people in tests or docs. Use studio-mac, alice@example.com.

Before you open a pull request

cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo clippy --target x86_64-pc-windows-gnu --all-targets -- -D warnings
cargo clippy --target aarch64-apple-darwin --all-targets -- -D warnings
cargo test

Say which platforms you actually ran on. Sign off commits (git commit -s). See CONTRIBUTING.md for layout and process, SECURITY.md for the threat model and reporting.

For coding agents specifically

  • Read docs/architecture.md before changing anything under src/server or src/desktop.
  • Do not weaken a check to make a test pass. If a security check blocks a legitimate use, raise it in the PR description instead.
  • Do not run rdc serve on the developer’s machine without being asked; it exposes their desktop to the allowlist. Use --target local or --dev-loopback for testing.
  • Keep prose in docs plain: state facts, commands and numbers; no metaphors or summarising quips.

Install

rdc is a single binary. Install it on the machine you want to control and on the machine your agent runs on. Both need to be on the same Tailscale tailnet.

Release binaries

Each tagged release on GitHub attaches:

FilePlatform
rdc-linux-x86_64.tar.gzLinux, x86_64, glibc
rdc-macos-arm64.tar.gzmacOS 15+, Apple silicon
rdc-windows-x86_64.zipWindows 10/11, x86_64
SHA256SUMSchecksums for the above

Unpack and put rdc (or rdc.exe) somewhere on your PATH. Verify with sha256sum -c SHA256SUMS.

Unsigned binaries

The release binaries are not code-signed or notarized.

  • macOS: Gatekeeper refuses to run the downloaded binary. You can clear the flag with xattr -d com.apple.quarantine rdc, but for the daemon you should not. macOS ties the Screen Recording and Accessibility permissions to the exact code hash of an unsigned binary, so every upgrade silently loses them. Build on the Mac and sign with your own certificate instead; macOS setup walks through it and a self-signed certificate is enough. The unsigned binary is fine for the client side (rdc -t …, rdc mcp).
  • Windows: SmartScreen warns on first run. “More info” → “Run anyway”, or build from source.
  • Linux: no signature checks apply.

With cargo

cargo install --git https://github.com/bscott/rdc --locked

Requires a recent stable Rust (the repo pins stable via rust-toolchain.toml; 1.90 or newer is known to work).

Build from source

git clone https://github.com/bscott/rdc
cd rdc
cargo build --release
./target/release/rdc --version

Build dependencies

Linux (Debian/Ubuntu names; the CI workflow uses exactly this list):

sudo apt-get install -y pkg-config libclang-dev libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev \
  libxrandr-dev libdbus-1-dev libpipewire-0.3-dev libwayland-dev libegl-dev libxkbcommon-dev libgbm-dev

Arch: pacman -S clang pkgconf libxcb libxrandr dbus pipewire wayland libglvnd libxkbcommon mesa.

macOS: Xcode Command Line Tools (xcode-select --install) and Rust. Nothing else.

Windows: Rust with the MSVC toolchain (the default from rustup). Nothing else.

Where things live

LinuxmacOSWindows
config~/.config/rdc/config.toml~/Library/Application Support/rdc/config.toml%APPDATA%\rdc\config.toml
service~/.config/systemd/user/dev.rdc.daemon.service~/Library/LaunchAgents/dev.rdc.daemon.plistscheduled task dev.rdc.daemon.<user>
daemon logjournalctl --user -u dev.rdc.daemon~/Library/Application Support/rdc/serve.log%LOCALAPPDATA%\rdc\serve.log
audit log~/.local/state/rdc/audit.jsonl~/Library/Application Support/rdc/audit.jsonl%LOCALAPPDATA%\rdc\audit.jsonl

Next: Configuration, then the setup guide for your target platform.

Configuration

rdc reads one TOML file. If it is missing, defaults apply and everything can be given on the command line instead.

PlatformPath
Linux~/.config/rdc/config.toml
macOS~/Library/Application Support/rdc/config.toml
Windows%APPDATA%\rdc\config.toml

rdc doctor prints the path it is using. Unknown keys anywhere in the file are errors, so a typo such as caps instead of can stops the daemon from starting rather than silently granting more than intended.

Full example

[serve]
port = 7770
# Optional. Default: this machine's Tailscale IPv4. Must be a Tailscale address.
# bind = "100.101.102.103"

# Who may control this machine, and how much. Empty = daemon refuses to start.
# A plain string grants everything; an inline table limits it to some capabilities.
allow = [
  "you@example.com",                                        # full control
  { who = "monitor-bot", can = "view" },                    # screenshots only
  { who = ["tag:ops", "bob@example.com"], can = ["view", "clipboard"] },
]

# Optional. Extra names clients may use in the URL besides this node's Tailscale IPs,
# MagicDNS name and hostname (e.g. a CNAME you point at it). Keep this above any
# [[serve.grant]] block; TOML would otherwise attach it to the grant.
# hosts = ["desk.internal.example"]

# The same thing as a block, if you prefer one grant per section.
[[serve.grant]]
who = "tag:family"
can = "all"

[serve.audit]
enabled = true                 # default
# path = "/var/log/rdc/audit.jsonl"   # default: rdc/audit.jsonl in the platform state dir
max_size_mb = 50               # rotate above this size
keep = 5                       # keep audit.jsonl.1 … .5

# Names you can pass to `--target` on the client side.
[targets.studio-mac]
url = "http://studio-mac.example-tailnet.ts.net:7770"

[targets.workshop-pc]
url = "http://100.64.10.20:7770"

[serve]

KeyDefaultMeaning
port7770TCP port for the daemon
bindTailscale IPv4Address to listen on. Anything that isn’t a Tailscale address (100.64.0.0/10 or fd7a:115c:a1e0::/48) is rejected at startup.
allow[]Grants: plain identity strings (full control) or { who, can } tables, see below
grant[][[serve.grant]] blocks, same shape as the table form of allow
auditenabledAudit log settings, see below
hosts[]Extra accepted Host header names; the node’s own IPs, MagicDNS name and hostname are always accepted

Command-line equivalents: rdc serve --port 7771 --bind 100.x.y.z --allow a@b --allow tag:ops=view. --allow flags are added to the config grants; who=cap,cap limits capabilities, a bare identity grants all.

Grants and capabilities

Each grant names one or more identities (who) and what they may do (can):

CapabilityAllows
viewdisplays, windows, screenshot, whoami
inputmouse, keyboard, focus
clipboardreading and writing the clipboard
alleverything (the default when can is omitted, and what a plain string grants)

who and can each take one value or a list. When several grants match the same caller, their capabilities are combined. A caller that lacks a capability gets 403 forbidden with a message naming the missing one, and the attempt is written to the audit log. whoami and /health need a valid identity but no particular capability.

The Tailscale side: let the traffic through

rdc’s grants decide what an identity may do. Your Tailscale access policy decides whether that identity’s packets reach port 7770 at all. Both have to agree. If the policy blocks the connection, the client sees a timeout, not a 403, and nothing appears in rdc’s audit log because nothing arrived.

Tailscale’s default policy allows everything, so a new tailnet needs no change. If you have tightened it, add a rule. The cleanest pattern is to tag the machines that run rdc serve (for example tag:rdc-host) and open the port from the people and tags you name in rdc’s grants.

Current syntax (grants), in the policy file at login.tailscale.com/admin/acls:

{
  "tagOwners": {
    "tag:rdc-host": ["autogroup:admin"],
  },
  "grants": [
    // people who may control rdc hosts
    { "src": ["alice@example.com", "bob@example.com"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] },
    // a monitoring tag that only screenshots (rdc grant: can = "view")
    { "src": ["tag:monitor"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] },
  ],
}

Older syntax (acls), if your policy still uses it:

"acls": [
  { "action": "accept", "src": ["alice@example.com", "tag:monitor"], "dst": ["tag:rdc-host:7770"] },
]

Then tag the controlled machine (tailscale up --advertise-tags=tag:rdc-host or from the admin console) and write the matching rdc grants:

[serve]
allow = [
  "alice@example.com",
  "bob@example.com",
  { who = "tag:monitor", can = "view" },
]

Policy rules for the examples on this page

The full example at the top of this page has four grants. This is the policy that lets each of them through, assuming the controlled machine is tagged tag:rdc-host:

rdc grant (config.toml)Who the policy must allowPolicy grants entry
"you@example.com"that login{ "src": ["you@example.com"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] }
{ who = "monitor-bot", can = "view" }a specific device, named by its node name in rdcpolicies can’t name a node as src; give the device a tag (tag:monitor) and use { "src": ["tag:monitor"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] }, or list its Tailscale IP under "hosts" and use that name as src
{ who = ["tag:ops", "bob@example.com"], can = ["view", "clipboard"] }the tag and the login{ "src": ["tag:ops", "bob@example.com"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] }
[[serve.grant]] who = "tag:family"the tag{ "src": ["tag:family"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] }

Or, as one policy fragment covering all four (plus the tag definitions the rules need):

{
  "tagOwners": {
    "tag:rdc-host": ["autogroup:admin"],
    "tag:monitor":  ["autogroup:admin"],
    "tag:ops":      ["autogroup:admin"],
    "tag:family":   ["autogroup:admin"],
  },
  "grants": [
    { "src": ["you@example.com", "bob@example.com", "tag:ops", "tag:monitor", "tag:family"],
      "dst": ["tag:rdc-host"],
      "ip":  ["tcp:7770"] },
  ],
}

Legacy acls equivalent of that single rule:

{ "action": "accept",
  "src": ["you@example.com", "bob@example.com", "tag:ops", "tag:monitor", "tag:family"],
  "dst": ["tag:rdc-host:7770"] }

The policy only opens the port. What each caller may then do (view, input, clipboard) is still decided by the rdc grant, so a tag:monitor device reaches the daemon but gets 403 on anything but screenshots.

Notes:

  • src names in the policy and who names in rdc are the same identities: tailnet logins and tags. Node names work in rdc but not as a policy src; use tags for machines.
  • If the rdc host stays a user-owned device instead of a tagged one, use the owner’s login as dst (all of that user’s devices), or list the machine under hosts in the policy.
  • SSH and rdc are separate ports. Opening 7770 does not open 22, and rdc never needs 22.
  • Check the network path before blaming rdc: tailscale ping <host> from the client, then rdc -t <host> whoami. A timeout is the policy; a 403 is rdc.

Allowlist rules

Each request’s peer IP is resolved with tailscaled’s whois. The result has a node name and either a login name (user-owned devices) or one or more tags (tagged devices). Tailscale still reports the creating user’s profile for tagged devices, but rdc ignores it: a tagged device can only match by tag or node name, never by that user’s login. An entry matches when, case-insensitively:

  • it equals the caller’s login name, e.g. alice@github, alice@example.com;
  • it equals the caller’s node name (the short device name, without the tailnet suffix);
  • it equals one of the caller’s tags, e.g. tag:family;
  • it is *, which allows everyone on the tailnet who can reach the port.

Results are cached for 30 seconds per IP. Loopback connections are always refused unless the daemon was started with --dev-loopback.

Audit log

Every authorized request and every rejection is appended as one JSON object per line:

{"ts":"2026-09-09T16:08:55.979Z","peer":"100.64.0.7","login":"alice@example.com","node":"laptop",
 "method":"POST","path":"/v1/act","action":"input.click 100,100 Left x1","outcome":"denied",
 "status":403,"detail":"alice@example.com may not use `input` on this machine","ms":0}

outcome is ok, denied (host, identity or capability) or error. action describes the request without its payload: typed text is recorded only as a character count. Key chords, window selectors and error messages are recorded as sent, with control characters replaced, so a hostile value cannot break the file or the terminal you read it in. The file and its rotated copies are mode 0600. Read it with rdc audit (-n, --json, --path).

Default location: ~/.local/state/rdc/audit.jsonl (Linux), ~/Library/Application Support/rdc/audit.jsonl (macOS), %LOCALAPPDATA%\rdc\audit.jsonl (Windows).

Host check

Before identity, the daemon checks the request’s Host header against the names it answers to: its Tailscale IPs, its MagicDNS name, its short hostname, and anything in [serve].hosts. A request addressed to any other name gets 421 Misdirected Request. This stops a web page on an allowed machine from reaching the daemon through DNS rebinding, since the browser would send the attacker’s hostname. Use the Tailscale name or IP in your [targets] URLs.

[targets]

Each table under [targets] names a machine for the client side. url is the daemon’s base URL; use the Tailscale MagicDNS name or the Tailscale IP. Only plain http:// is needed since the tailnet is already encrypted.

Resolving --target

rdc -t VALUE … and rdc mcp --target VALUE accept, in order:

  1. local — control this machine directly, no daemon involved (default).
  2. A name from [targets].
  3. A full URL, http://host:port.
  4. A bare host or host:port; the port defaults to [serve].port.

File permissions

Whoever can edit config.toml can add themselves to [serve].allow, so the file is as sensitive as ~/.ssh/authorized_keys and rdc treats it the same way. On Linux and macOS, rdc serve and rdc service install refuse to start when the config file or its directory is owned by another user or is writable by group or others; rdc doctor reports the same check as config perms. World-readable is fine (the allowlist is not a secret), but the recommended layout is:

chmod 700 ~/.config/rdc            # macOS: ~/Library/Application\ Support/rdc
chmod 600 ~/.config/rdc/config.toml

Set RDC_INSECURE_CONFIG=1 to turn the refusal into a logged warning if you have a deliberate reason (a shared dotfiles checkout, say). Windows is not checked: the per-user ACL on %APPDATA% already restricts it to the profile’s owner and administrators.

Environment variables

VariableEffect
RDC_TARGETdefault for --target
RDC_LOGlog filter, e.g. debug, rdc=debug,hyper=warn (tracing syntax)
RDC_INSECURE_CONFIGset to 1 to run the daemon even if config.toml is writable by others (see File permissions)
RDC_SIGN_IDENTITYmacOS: code-signing identity name for scripts/macos/bundle-and-sign.sh (default rdc-dev)
RDC_ALLOW_ADHOCmacOS: set to 1 to let the bundle script fall back to ad-hoc signing

Grants and Tailscale policy

Two lists decide who can control a machine with rdc:

  1. The Tailscale access policy (in the admin console) decides whether a device’s packets reach port 7770 on the machine at all.
  2. rdc’s grants (in config.toml on the machine) decide what an identity that gets through may do: view, input, clipboard, or all.

A connection blocked by the policy times out and never appears in rdc’s audit log. A connection that reaches rdc but is not in a grant gets 403 and is logged. Both lists name the same kinds of identity: tailnet logins such as alice@example.com and tags such as tag:ops. rdc grants can also name a device by its node name; the policy cannot, so tag devices you want to reference there.

Every example below assumes the machine running rdc serve carries the tag tag:rdc-host. Tag it with tailscale up --advertise-tags=tag:rdc-host or from the admin console, and declare the tag under tagOwners in the policy.

One person, full control

The common case: you control your own machines from your own devices.

config.toml on the controlled machine:

[serve]
allow = ["alice@example.com"]

Policy (grants syntax):

{
  "tagOwners": { "tag:rdc-host": ["autogroup:admin"] },
  "grants": [
    { "src": ["alice@example.com"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] },
  ],
}

Legacy acls syntax:

"acls": [
  { "action": "accept", "src": ["alice@example.com"], "dst": ["tag:rdc-host:7770"] },
]

A view-only monitoring device

A device tagged tag:monitor may take screenshots but cannot click or type. In rdc it is limited to view; in the policy it needs the port open like anyone else.

[serve]
allow = [
  "alice@example.com",
  { who = "tag:monitor", can = "view" },
]
"grants": [
  { "src": ["alice@example.com", "tag:monitor"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] },
]

A request from that device for anything but screenshot, displays or windows returns 403 forbidden: … may not use input and is written to the audit log.

A family or team tag

Everyone whose device carries tag:family gets full control.

[serve]
allow = ["tag:family"]
"tagOwners": { "tag:rdc-host": ["autogroup:admin"], "tag:family": ["autogroup:admin"] },
"grants": [
  { "src": ["tag:family"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] },
]

Tailscale reports the user who created a tagged device; rdc ignores that login. A tagged device matches only by tag or node name.

Several grants for the same person

Grants add up. Here a person gets view and clipboard from one grant and input from another, so they end up with all three.

[serve]
allow = [
  { who = ["tag:ops", "bob@example.com"], can = ["view", "clipboard"] },
  { who = "bob@example.com", can = "input" },
]
"grants": [
  { "src": ["tag:ops", "bob@example.com"], "dst": ["tag:rdc-host"], "ip": ["tcp:7770"] },
]

Block-style grants

The same grants can be written as [[serve.grant]] blocks if you prefer one per section. Keep other [serve] keys such as hosts above the first block.

[serve]
port = 7770
hosts = ["desk.internal.example"]

[[serve.grant]]
who = "alice@example.com"

[[serve.grant]]
who = "tag:monitor"
can = "view"

A user-owned host instead of a tagged one

If the controlled machine is not tagged, use its owner’s login as the policy destination (this covers all of that user’s devices) or list its Tailscale IP under hosts in the policy:

"hosts": { "studio-mac": "100.101.102.103" },
"grants": [
  { "src": ["alice@example.com"], "dst": ["studio-mac"], "ip": ["tcp:7770"] },
]

Mapping table

rdc grantPolicy srcNotes
"alice@example.com"alice@example.comlogin, full control
{ who = "tag:monitor", can = "view" }tag:monitorscreenshots only
{ who = "studio-laptop" }a tag on that device, or a hosts entrypolicies cannot name a node directly
"tag:family"tag:familyeveryone with the tag
"*"whoever the policy admitsrdc accepts any tailnet identity that reaches it

Checking the two lists agree

From the client machine:

tailscale ping studio-mac          # the tunnel works
rdc -t studio-mac whoami           # rdc admits you and shows your capabilities

A timeout on the second command means the policy; a 403 means the rdc grant. On the daemon machine, rdc audit -n 20 shows what arrived and how it was decided.

macOS setup

Verified on macOS 15 and 26 (Tahoe) on Apple silicon. This guide is for the machine being controlled; the client side just needs the binary.

Why signing matters

macOS gates screen capture and synthetic input behind two permissions, Screen Recording and Accessibility, granted per application in System Settings. The grant is keyed to the app’s code signature. An unsigned or ad-hoc-signed binary is identified by its exact hash, so every rebuild or upgrade loses the grants and you are back to clicking prompts. A signed .app bundle with a stable certificate keeps the grants across upgrades. A self-signed certificate you make yourself is sufficient; no Apple developer account is needed.

Everything below assumes you build on the Mac. Cross-compiling from Linux is possible but you still need the Mac to sign.

Steps

  1. Rust and the source

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
    git clone https://github.com/bscott/rdc ~/code/rdc
    
  2. Create the signing identity, once, from Terminal in the GUI session (not over SSH; the trust step needs your login password):

    ~/code/rdc/scripts/macos/make-signing-identity.sh
    

    This creates a self-signed code-signing certificate named rdc-dev in a dedicated keychain (~/Library/Keychains/rdc-signing.keychain-db) with its password in ~/.config/rdc/signing-keychain-pass (mode 0600), so later builds can sign non-interactively, including over SSH. The login keychain is locked in SSH sessions, which is why a separate keychain is used.

  3. Build, bundle and sign

    ~/code/rdc/scripts/macos/bundle-and-sign.sh
    

    Produces ~/Applications/rdc.app, signed with rdc-dev. The script refuses to fall back to ad-hoc signing unless RDC_ALLOW_ADHOC=1, for the reason above.

  4. Configure the allowlist

    mkdir -p ~/Library/Application\ Support/rdc
    cat > ~/Library/Application\ Support/rdc/config.toml <<'EOF2'
    [serve]
    port = 7770
    allow = ["you@example.com"]
    EOF2
    
  5. Install the LaunchAgent from inside the bundle

    ~/Applications/rdc.app/Contents/MacOS/rdc service install
    

    The daemon starts, logs to ~/Library/Application Support/rdc/serve.log, and pops the two permission prompts on the Mac’s screen.

  6. Grant the permissions on the Mac’s console (or via a KVM). Click “Open System Settings” on each prompt and turn on rdc under Privacy & Security → Screen & System Audio Recording and Privacy & Security → Accessibility. If rdc isn’t listed, add it with the + button and pick ~/Applications/rdc.app (in the file picker: Locations → your home → Applications).

    macOS may also show a second dialog saying rdc wants to “bypass the system private window picker”; allow it. It appears because the capture fallback path uses an older API.

  7. Restart and verify

    launchctl kickstart -k gui/$(id -u)/dev.rdc.daemon
    grep permission ~/Library/Application\ Support/rdc/serve.log | tail -2
    ~/Applications/rdc.app/Contents/MacOS/rdc doctor
    

    Both permissions should read granted, and doctor should show a screenshot in well under a second with a window count larger than one.

Upgrading

cd ~/code/rdc && git pull && ./scripts/macos/bundle-and-sign.sh
launchctl kickstart -k gui/$(id -u)/dev.rdc.daemon

Because the certificate is unchanged, the permissions carry over. No prompts.

Things to know

  • Monthly re-consent. Since macOS 15, the system periodically asks you to re-confirm Screen Recording for every app that uses it. When that happens screenshots show only the wallpaper; rdc doctor reports the permission missing and the daemon log says so on restart. Click the prompt once and restart the daemon.

  • Stuck grants. If Settings shows rdc enabled but the daemon still reports “NOT granted”, the entry belongs to an older signature. Clear it and let the daemon re-prompt:

    tccutil reset ScreenCapture dev.rdc.daemon
    tccutil reset Accessibility dev.rdc.daemon
    launchctl kickstart -k gui/$(id -u)/dev.rdc.daemon
    
  • Jump to a Settings pane from SSH when you’re driving the Mac remotely:

    open "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
    open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
    
  • Tailscale variants. rdc finds the Tailscale LocalAPI for the App Store app, the standalone Tailscale.app (system extension) and open-source tailscaled. Nothing to configure.

  • Bundle identifier. dev.rdc.daemon is the bundle id and LaunchAgent label, defined in scripts/macos/bundle-and-sign.sh and src/service/mod.rs.

  • Screenshots use the system screencapture tool, which is fast (about 0.3 s for a 2560×1440 display). The CoreGraphics fallback is much slower on recent macOS.

  • Window focus activates the owning application; raising one specific window of a multi-window app is not implemented.

Linux setup

Verified on Arch Linux with Hyprland (Wayland). Other Wayland compositors and X11 compile and should work but are untested; please report what you find.

Wayland

How rdc talks to the compositor

NeedMechanismWorks on
Screen captureorg.freedesktop.portal.Screenshot via xdg-desktop-portal, falling back to wlr-screencopyGNOME, KDE, Hyprland, sway, river…
Mouse and keyboardwlr-virtual-pointer + zwp-virtual-keyboard protocolswlroots compositors (Hyprland, sway, river, labwc…)
Window list and focushyprctl when HYPRLAND_INSTANCE_SIGNATURE is setHyprland only
Clipboardwlr-data-controlwlroots compositors and KDE

GNOME and KDE do not implement the wlr virtual input protocols, so on those desktops rdc can take screenshots but cannot move the mouse or type yet. The input library rdc uses (enigo) has paths through the RemoteDesktop portal and libei, but rdc does not enable them; wiring and testing them is tracked as an issue. rdc doctor reports the session type and whether input initialised.

rdc pins exactly one input backend per session (Wayland when WAYLAND_DISPLAY is set, X11 otherwise) so events are not delivered twice to Xwayland applications.

Absolute pointer positioning under Wayland is expressed as a fraction of the first output’s mode, which rdc maps from logical desktop coordinates, so clicks land correctly on scaled displays (verified at 2× on Hyprland).

Packages

Runtime: a portal backend for your compositor (xdg-desktop-portal-hyprland, xdg-desktop-portal-gnome, xdg-desktop-portal-kde, or xdg-desktop-portal-wlr), PipeWire, and tailscaled running. Build dependencies are listed in Install.

X11

Capture and input go through xcb/x11rb. Window focus uses the generic xcap window list (no _NET_ACTIVE_WINDOW focus yet, so focus is unsupported on X11 for now).

Running

Put the allowlist in ~/.config/rdc/config.toml first (see Configuration); the service reads it from there.

rdc doctor
rdc serve                                  # foreground test
rdc service install                        # systemd --user unit dev.rdc.daemon.service
journalctl --user -u dev.rdc.daemon -f

The unit is wanted by graphical-session.target, so it starts with your desktop session and restarts on failure. It runs the binary from the path where you invoked service install; put rdc somewhere permanent first (~/.local/bin or /usr/local/bin).

Config lives at ~/.config/rdc/config.toml; see Configuration.

Tailscale

rdc uses the LocalAPI socket at /var/run/tailscale/tailscaled.sock. If your user can’t read it, rdc doctor shows the failure and rdc falls back to the tailscale CLI. On most distributions the socket is world-connectable for read-only calls like whois and status.

Windows setup

Verified on Windows 11 Home (build 26200) on an HP Omen laptop, single display at 150 % scaling. This guide is for the machine being controlled; the client side just needs the binary.

What works

  • Screenshots (about 0.3 s for 2560×1600), window list with titles, focus, mouse, keyboard, clipboard, Tailscale identity through the LocalAPI named pipe, the audit log.
  • rdc service install creates a Task Scheduler logon task that runs the daemon in your desktop session as a standard user, with a log file. Survives reboots and sign-in.

Not yet verified: multiple monitors (the code path exists, see issue #1), Windows 10.

How it has to run

Two Windows facts shape the setup:

  1. The daemon must live in your interactive desktop session. A Windows service or an SSH session runs in session 0, which has no real display: rdc doctor there reports a fake 1024×768 monitor and screenshots fail. rdc service install therefore uses a scheduled task that runs at logon as you, not a service.
  2. It runs as a standard user by default. The task uses your normal, filtered token (LeastPrivilege run level), so a remote-control process that anyone on your allowlist can drive holds no more privilege than any app you double-click. The trade-off is UIPI: Windows silently drops synthetic input aimed at elevated windows (an Administrator PowerShell, an installer) and refuses to move focus to them from a lower-integrity process. If you need to drive elevated windows remotely, rdc service install --elevated creates the task at the highest run level instead; understand that this leaves a permanently elevated process listening on your tailnet. UAC prompts on the secure desktop are out of reach either way, by design.

Steps

  1. Get the binary. Download rdc-windows-x86_64.zip from the releases page (SmartScreen will warn once; it is unsigned) or build from source with the MSVC toolchain:

    winget install Git.Git
    # Visual Studio Build Tools with the C++ workload (MSVC + Windows SDK)
    Invoke-WebRequest https://aka.ms/vs/17/release/vs_BuildTools.exe -OutFile $env:TEMP\vs_BuildTools.exe
    & $env:TEMP\vs_BuildTools.exe --quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
    Invoke-WebRequest https://win.rustup.rs/x86_64 -OutFile $env:TEMP\rustup-init.exe
    & $env:TEMP\rustup-init.exe -y --profile minimal
    git clone https://github.com/bscott/rdc; cd rdc; cargo build --release
    

    Put rdc.exe somewhere permanent, e.g. %LOCALAPPDATA%\Programs\rdc\rdc.exe. The scheduled task points at the path you install from.

  2. Config at %APPDATA%\rdc\config.toml:

    [serve]
    port = 7770
    allow = ["you@example.com"]
    
  3. Firewall. Allow the port from the tailnet only. The daemon binds just the Tailscale address regardless, but scoping the rule means a future misconfiguration cannot expose it:

    New-NetFirewallRule -DisplayName "rdc (Tailscale)" -Direction Inbound -Protocol TCP -LocalPort 7770 `
      -RemoteAddress 100.64.0.0/10 -InterfaceAlias Tailscale -Action Allow
    
  4. Install the task from a normal PowerShell, signed in as the account that uses the desktop. The default task runs at standard integrity and needs no Administrator shell to register. Only --elevated (highest run level) has to be run from an elevated PowerShell; rdc service install --elevated refuses otherwise.

    rdc doctor                       # tailscaled, config, grants; display info is only real from the desktop
    rdc service install              # creates and starts task dev.rdc.daemon as a standard user
    rdc service install --elevated   # only if you must drive elevated windows (see above)
    rdc service status
    

    The task is named dev.rdc.daemon.<username>, one per account. It is registered from an XML definition with no battery restrictions, no run-time limit, and one instance at a time.

    Logs: %LOCALAPPDATA%\rdc\serve.log. Audit: %LOCALAPPDATA%\rdc\audit.jsonl.

  5. Verify from the client machine: rdc -t <omen> whoami, shot, windows, focus.

Upgrading

Copy the new rdc.exe over the old one after rdc service uninstall (which stops the daemon started from that binary and nothing else), then rdc service install again. Or run service install with the new binary in place; it stops the previous instance first.

Things to know

  • Coordinates are physical pixels. Windows reports monitor geometry and captures screenshots in physical pixels, so on a 150 % display a 2560×1600 screen is 2560×1600 points to rdc. That’s consistent between screenshots and clicks, which is all that matters for the MCP mapping.
  • Multi-monitor. Pointer moves use SendInput normalised against the whole virtual desktop, so secondary displays should work, but this is untested until someone runs it with two screens.
  • Elevated windows. With the default (non-elevated) task, clicks and keystrokes aimed at an elevated window are dropped and focus on it fails; the audit log records the action as successful because Windows gives no error. Either close the elevated window, or reinstall with --elevated.
  • Focus. Windows only lets a process take the foreground if it recently sent input. rdc attaches to the foreground thread’s input queue (skipping threads that don’t respond within 200 ms) and, if that is refused, sends a zero-length mouse move and retries.
  • SSH for administration. Enable OpenSSH Server (Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0), put your key in C:\ProgramData\ssh\administrators_authorized_keys for admin accounts, and set PowerShell as the default shell via HKLM:\SOFTWARE\OpenSSH\DefaultShell. Remember that an SSH session is session 0: use it to build and to manage the task, not to run rdc serve directly.
  • Tailscale. The LocalAPI is reached over the named pipe; the tailscale.exe CLI is the fallback. Turn on unattended mode in the Tailscale tray menu so the machine stays reachable while signed out.

MCP tools

rdc mcp --target NAME speaks the Model Context Protocol over stdio, so any MCP client can use it. It exposes one machine per server process; run several for several machines.

Claude Code setup

Project-local, in .mcp.json at the repo root (an example ships as .mcp.json.example):

{
  "mcpServers": {
    "studio-mac": { "command": "rdc", "args": ["mcp", "--target", "studio-mac"] }
  }
}

Or globally in ~/.claude.json under the same mcpServers key. rdc must be on Claude Code’s PATH; otherwise give the absolute path in command. --target takes the same values as the CLI: a name from [targets], a host, a URL, or local.

Also drop the repo’s skills/rdc/ folder into ~/.claude/skills/ (or your agent’s skill directory). It teaches the agent when and how to use these tools, including the coordinate rules below.

The coordinate model

The agent never deals with display scaling or multi-monitor offsets:

  1. screenshot returns an image (downscaled so its longer edge is at most 1568 px by default) plus a text line stating the desktop region it covers.
  2. Every x/y the agent passes to click, mouse_move, drag or scroll is a pixel position in the most recent screenshot.
  3. rdc maps that pixel to a logical desktop point and sends it to the daemon.
  4. Most actions return a fresh screenshot, so the mapping stays current.

If the agent calls an action before any screenshot, rdc takes one silently to establish the mapping. Coordinates read from an old screenshot taken with a different max are wrong; the skill tells agents to always click from the latest image.

Tools

Parameters marked † default to true and mean “return a screenshot after the action”; pass false to skip it when chaining several actions.

ToolParametersReturns
screenshotdisplay (all default, primary, or id), max (px, default 1568)image + region text
displaysJSON list of displays
windowsJSON list; each window has desktop rect and, once a screenshot exists, an image rect in current screenshot pixels
focusone of id, app (substring), title (substring); then_screenshottext (+ image)
mouse_movex, y; then_screenshottext (+ image)
clickx, y, button (left default, right, middle), count (1–3); then_screenshottext (+ image)
dragx1, y1, x2, y2, button; then_screenshottext (+ image)
scrolloptional x, y; dx, dy in wheel steps (positive = right / down); then_screenshottext (+ image)
typetext; then_screenshottext (+ image)
keychord, e.g. enter, cmd+q, ctrl+shift+t; then_screenshottext (+ image)
clipboard_getclipboard text
clipboard_settextconfirmation

screenshot, displays, windows and clipboard_get are annotated read-only.

Errors come back as MCP errors with the daemon’s message, for example "bogus" is not a modifier in "bogus+q" or no window matches App("Foo").

A typical exchange

agent → screenshot
      ← [image 1568×882] "studio-mac: 1568x882 image of desktop region x=0 y=0 w=2560 h=1440 …"
agent → windows
      ← [{ "app": "Installer", "title": "Install Foo", "image": {"x": 612, "y": 300, "w": 340, "h": 220}, … }]
agent → click { "x": 780, "y": 480 }
      ← "Left click x1 at image (780, 480) = desktop (1273, 784)" + [fresh image]
agent → key { "chord": "enter", "then_screenshot": false }
      ← "pressed enter"

Tuning

  • rdc mcp --max 1200 sends smaller images (cheaper, less detail); --max 2000 the reverse.
  • Tool calls are serialized inside one server process, so a burst of calls keeps its order.
  • Actions wait about 350 ms before the follow-up screenshot so the UI can settle.

CLI reference

rdc [-t TARGET] [--log FILTER] <command>

-t/--target selects the machine for every client command (default local; see Configuration). --log sets the tracing filter (info by default); logs go to stderr, or to a file with --log-file PATH (RDC_LOG_FILE).

Daemon side

rdc serve

Run the daemon on the machine to be controlled. Binds the Tailscale IPv4 on port 7770 unless told otherwise, verifies every caller through tailscaled whois, and refuses to start with an empty allowlist.

FlagMeaning
--allow WHO[=CAPS]identity to permit, optionally limited: --allow tag:ops=view,clipboard (repeatable, added to config)
--port Nlisten port
--bind IPlisten address (must be a Tailscale address)
--dev-loopbackbind 127.0.0.1 and skip authentication for loopback. Testing only.

rdc service install [--elevated] | uninstall | status

Manage a per-user background service that runs rdc serve: a LaunchAgent on macOS (dev.rdc.daemon), a systemd --user unit on Linux, a Task Scheduler logon task on Windows. On Windows the task runs at standard integrity; --elevated requests the highest run level so the daemon can drive elevated windows (see Windows setup). The service runs the binary at the path rdc service install was invoked from, so install from the final location (on macOS, from inside rdc.app).

rdc audit [-n N] [--json] [--path FILE]

Show the last N entries (default 50) of this machine’s audit log as a table, or as raw JSON lines with --json. The file is [serve.audit].path or the platform default. See Configuration.

rdc doctor [--request-permissions]

Prints what rdc can see on this machine: platform and session type, tailscaled connectivity and this node’s identity, permission state, displays with geometry, a timed test screenshot, window count, whether input initialises, the configured grants, and the audit log path. Exits non-zero if something needed for serving is missing. --request-permissions (macOS) triggers the Screen Recording and Accessibility prompts.

Client side

All coordinates are logical desktop points in the virtual desktop that spans every monitor. rdc displays shows each monitor’s rectangle in that space.

CommandWhat it does
rdc displayslist displays as JSON: id, name, rect, scale, primary
rdc windowslist windows as JSON: id, pid, app, title, rect, focused, minimized
rdc shot [--display all|primary|ID] [--max PX] [--jpeg] [-o FILE]screenshot. --max limits the longer edge. Prints the output path; the desktop rect it covers goes to stderr.
rdc move X Ymove the pointer
rdc click X Y [--button left|right|middle] [--double]click
rdc drag X1 Y1 X2 Y2 [--button …]press, move in steps, release
rdc scroll [--dx N] [--dy N] [--at X Y]wheel steps; positive dy scrolls down, positive dx right
rdc type TEXTtype literal text (unicode ok)
rdc key CHORDpress a chord, see below
rdc focus --id ID | --app SUBSTR | --title SUBSTRbring a window forward
rdc clip [TEXT]read the clipboard, or set it
rdc whoamiask the daemon how it identifies you (remote targets only)

Mapping a screenshot pixel to a click

A screenshot of size W×H that covers desktop rect (x, y, w, h) maps pixel (px, py) to (x + px·w/W, y + py·h/H). On a 2× display, a full-resolution screenshot is twice the size of the logical desktop, so halve pixel coordinates. rdc mcp does this automatically; the CLI does not.

Key chords

Tokens joined by +; all but the last are modifiers.

Modifierscmd command super win meta (all = the platform’s Meta key), ctrl control, alt option, shift
Named keysenter/return, esc/escape, tab, space, backspace, delete, insert, home, end, pageup, pagedown, up down left right, capslock, f1f24
Punctuation namesplus minus comma period slash backslash semicolon quote grave equal bracketleft bracketright
Anything elsea single character, typed with the modifiers held

Examples: cmd+shift+4, ctrl+c, alt+f4, enter, ctrl+plus, +.

MCP server

rdc mcp [--target T] [--max PX]

Serve MCP over stdio for an agent. --max sets the longest screenshot edge sent to the model (default 1568). See MCP tools.

Troubleshooting

Start with rdc doctor on the machine being controlled. It checks each layer in order and says which one failed.

Daemon won’t start

MessageMeaningFix
allowlist is emptyno --allow and no [serve].allowadd at least one entry
X is not a Tailscale address; refusing--bind or config points at a LAN/public IPbind the Tailscale IP or leave bind unset
tailscaled unreachable / no Tailscale IPTailscale not running or not logged intailscale status; on macOS make sure the Tailscale app is running in the same user session
bind … Address already in useanother rdc or something else on 7770--port

Client can’t connect

SymptomCauseFix
connection refused / timeoutdaemon not running, wrong host, you’re not on the tailnet, or the Tailscale policy blocks tcp/7770tailscale ping HOST; check rdc service status on the target; add a policy rule for tcp:7770 (see Configuration)
403 … is not in the allowlistwhois succeeded but you’re not allowedadd your login/node/tag; rdc -t HOST whoami shows what the daemon sees once allowed, the 403 message shows it when not
403 … is not a Tailscale addressrequest arrived from a non-tailnet IPuse the Tailscale hostname or 100.x address
403 forbidden: … may not use \input``your grant is limited to some capabilitieswiden can in the grant on the daemon side; rdc -t HOST whoami shows your caps
403 loopback connections are not acceptedyou’re on the same machineuse --target local, or start the daemon with --dev-loopback for testing
421 request addressed to unexpected hostthe URL uses a name the daemon doesn’t recognise as itselfuse the Tailscale MagicDNS name or IP, or add the name to [serve].hosts
400 point (…) is outside the desktopcoordinates beyond the displayscheck rdc displays; with MCP, take a fresh screenshot
400 scroll steps must be within ±100scroll amount too largescroll in smaller steps

Screenshots

SymptomCauseFix
macOS: image is only the wallpaper, windows lists 1 entryScreen Recording not granted to this exact buildmacOS setup: re-grant, tccutil reset, avoid ad-hoc signing
macOS: screenshots take many secondsscreencapture failed and rdc fell back to CoreGraphicscheck the daemon log for screencapture failed; usually a permission problem
Wayland: xcap: … errorno portal backend or screencopy supportinstall xdg-desktop-portal-<compositor>; on GNOME the portal Screenshot dialog may need approving once
black image on X11compositor/driver quirktry --display primary

Input

SymptomCauseFix
macOS: permission denied: Accessibility or clicks do nothingAccessibility not grantedgrant to rdc.app, restart daemon
Wayland: no way to move the mousecompositor lacks the wlr virtual pointer protocol (GNOME, KDE)not supported yet; capture works, input does not (issue #2)
clicks land in the wrong placecoordinates from a stale or differently sized screenshot; or CLI given image pixels instead of desktop pointstake a new screenshot; use rdc mcp, or convert as in CLI reference
key: "foo" is not a modifiertypo in the chordsee the chord grammar in the CLI reference
Windows: input ignored or focus refused for an elevated appUIPI; the daemon runs at standard integrity by defaultclose the elevated window, or reinstall with rdc service install --elevated if you accept a permanently elevated daemon
Windows: doctor shows a 1024×768 “WinDisc” display and screenshots failyou’re in an SSH or service session (session 0)run the daemon via rdc service install; test from the client machine

MCP

SymptomFix
Claude Code shows the server as failedrun rdc mcp --target NAME in a terminal; it should sit waiting on stdin with no errors. Common causes: rdc not on PATH, unknown target name, config parse error
tools work but images are huge/slowrdc mcp --max 1200
the agent clicks the wrong thing after a screenshot with a custom maxthe skill tells agents to click from the latest image; remind it

Logs

  • Daemon: RDC_LOG=debug rdc serve … (foreground); service logs are in journalctl --user -u dev.rdc.daemon (Linux) or ~/Library/Application Support/rdc/serve.log (macOS).
  • Every request is logged with the resolved identity; rejections are logged at warn.
  • The audit log has one line per request or rejection: rdc audit -n 100 on the daemon machine.
  • Client and MCP: --log debug or RDC_LOG=debug; goes to stderr, so it won’t corrupt MCP stdio.

Contributing

Thanks for your interest. Bug reports with rdc doctor output, platform test reports, and small focused pull requests are all welcome.

Before you start

  • Read AGENTS.md: the design and security principles changes are reviewed against.

  • For anything beyond a small fix, open an issue first so we can agree on the approach. The architecture doc explains how the pieces fit.

  • Security problems: do not open a public issue. See SECURITY.md.

  • Platform reports are contributions too. If you run rdc on a platform marked untested in the README, tell us what happened, good or bad.

Development setup

git clone https://github.com/bscott/rdc && cd rdc
cargo build --release
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --all

Build dependencies per OS are in docs/install.md. CI runs the same four commands on Linux, macOS and Windows with warnings as errors, so run them before pushing.

Trying changes locally without a second machine

./target/release/rdc serve --dev-loopback              # unauthenticated 127.0.0.1, testing only
./target/release/rdc -t http://127.0.0.1:7770 shot
./target/release/rdc -t http://127.0.0.1:7770 click 400 300

Or skip the daemon entirely with --target local, which exercises the same Desktop implementation in-process.

Trying the MCP server

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | ./target/release/rdc mcp -t local

Repository layout

PathContents
src/proto.rswire and domain types shared by daemon, client, CLI and MCP
src/desktop/mod.rsthe Desktop trait
src/desktop/local/in-process implementation: capture, input worker, per-platform window code
src/desktop/remote.rsHTTP client implementation
src/server/axum daemon, whois auth middleware, routes
src/tailscale.rsLocalAPI discovery per platform, CLI fallback
src/mcp.rs, src/view.rsMCP tools and pixel↔point mapping
src/keys.rskey chord grammar
src/service/LaunchAgent and systemd installers
src/doctor.rs, src/permissions.rsreadiness checks and macOS TCC helpers
scripts/macos/signing identity and .app bundling
skills/rdc/the agent skill
docs/user documentation

Pull request checklist

  • cargo fmt, cargo clippy --all-targets -- -D warnings and cargo test pass locally.
  • Say which platforms you actually ran on, and how (foreground, service, MCP).
  • If behaviour changed, update the relevant page in docs/ and, if it affects agents, skills/rdc/SKILL.md.
  • Add a line under Unreleased in CHANGELOG.md; it becomes the release notes.
  • If you verified a platform that the README marks untested, update the status table.
  • Commits are descriptive; one logical change per commit where practical.

Style

  • Keep the Desktop trait the single seam: new capabilities go there first, then to the wire API, then to the CLI and MCP. All three surfaces must stay equivalent.
  • No new network listeners, no shell execution, no credentials in the tree.
  • Prefer returning RdcError over panicking in daemon paths.
  • Platform-specific code goes behind cfg(target_os = …) in its own module; keep the common path compiling on all three OSes. From Linux, after rustup target add x86_64-pc-windows-gnu aarch64-apple-darwin, run cargo clippy --target <triple> --all-targets -- -D warnings for both; CI runs clippy with warnings as errors on every platform, and a lint that only fires on one of them will fail the build there.

License and sign-off

rdc is licensed under the GPL-3.0-or-later. By contributing you agree that your contributions are licensed under the same terms.

Please sign off each commit (git commit -s), which adds a Signed-off-by: line certifying the Developer Certificate of Origin: that you wrote the change or otherwise have the right to submit it under the project license.

Changelog

Entries under Unreleased go into the next release’s notes; scripts/release-notes.sh builds the GitHub release body from the matching ## <version> section.

Unreleased

Fixed

  • Audit completeness that 0.3.0’s notes claimed but did not ship: whoami, /health, unknown routes, malformed action bodies and screenshot parameter errors are now written to the audit log. The 0.3.0 release only fixed the middleware’s recorded status.

Changed

  • Windows: the scheduled task no longer runs elevated by default (thanks @Mrigbozurike). rdc service install now registers the logon task at LeastPrivilege run level, so the daemon holds only the user’s standard token and the install itself no longer needs an Administrator shell. Input aimed at elevated windows is dropped by UIPI in this mode; pass rdc service install --elevated (from an elevated PowerShell) to get the previous HighestAvailable behaviour. Existing installs keep their run level until reinstalled.
  • Windows setup docs scope the firewall rule to the tailnet (100.64.0.0/10, Tailscale interface).
  • Docs: the configuration guide now shows the Tailscale access-policy rule (grants and acls forms) that must accompany rdc’s grants, and the troubleshooting table distinguishes a policy timeout from an rdc 403.

0.3.0 — 2026-09-10

Added

  • Config file permission check (Unix) (thanks @Mrigbozurike). rdc serve and rdc service install refuse to run when config.toml or its directory is owned by another user or writable by group/others, because editing the allowlist is equivalent to desktop access. rdc doctor reports the check as config perms; RDC_INSECURE_CONFIG=1 downgrades the refusal to a warning.
  • Windows support, verified on Windows 11. rdc service install creates an elevated Task Scheduler logon task that runs the daemon in the interactive session with a log file; service uninstall stops the running daemon. Window focus implemented (foreground-thread attach with an Alt-tap fallback). Absolute pointer moves use SendInput over the whole virtual desktop so secondary monitors are addressable (untested on real hardware yet).
  • --log-file / RDC_LOG_FILE to append logs to a file instead of stderr.

Fixed

  • Windows: the service path no longer carries the \\?\ verbatim prefix.

0.2.1 — 2026-09-09

Changed

  • License is now GPL-3.0-or-later (was AGPL-3.0-or-later). rdc is a program you run on your own machines, not a hosted service, so the AGPL network clause added friction without protecting anything; plain GPL keeps the requirement that distributed modifications are published. There are no external contributions to date, so no consent was needed.
  • GitHub releases carry the changelog section for the version as their notes.
  • CONTRIBUTING asks for DCO sign-off (git commit -s).

0.2.0 — 2026-09-09

Added

  • Capabilities. Grants can limit an identity to view, input and/or clipboard. Config accepts plain strings (full control), inline tables { who = "...", can = [...] } inside allow, or [[serve.grant]] blocks; the CLI accepts --allow who=view,clipboard. Missing capability → 403 forbidden. whoami reports caps.
  • Audit log. One JSON line per authorized request or rejection (host, identity and capability denials included) with identity, action summary, outcome and duration. Mode 0600, size-rotated, configurable under [serve.audit]. New rdc audit command to read it.
  • rdc doctor prints the configured grants and the audit log path.

Changed

  • --allow and [serve].allow entries are now grants; existing plain-string configs behave exactly as before (full control).

0.1.0 — 2026-09-08

First public release. Remote desktop control for AI agents over Tailscale: screenshot, mouse, keyboard, window focus and clipboard as MCP tools and a CLI. Tailscale whois identity with an allowlist, Host-header check, input validation. Verified on Linux (Hyprland) and macOS (Apple silicon); Windows compiles but is untested.