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 servelistens only on the machine’s Tailscale address and asks the localtailscaledwho each caller is. You allow tailnet logins, device names or tags, and can limit each toview,inputorclipboard. - 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 clicks | How rdc works |
| install it | Install, then the guide for your platform |
| decide who may connect and what they may do | Grants and Tailscale policy |
| wire it into Claude Code | MCP tools |
| fix something | Troubleshooting |
Status
| Target platform | State |
|---|---|
| Linux, Wayland (Hyprland / wlroots) | verified |
| Linux, Wayland (GNOME, KDE) | screenshots only; input not wired up yet |
| Linux, X11 | compiles, untested |
| macOS 15+, Apple silicon | verified |
| Windows 11 | verified 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.
tailscaled which user or tags that device has.| Your machine | Machine being controlled | |
|---|---|---|
| Command | rdc mcp --target studio-mac or rdc -t studio-mac … | rdc serve |
| Job | Speaks MCP to the agent, converts screenshot pixels to desktop points | Identifies callers, captures the screen, sends input |
| Runs as | A process the agent starts | LaunchAgent (macOS), systemd user service (Linux), scheduled task (Windows) |
| Holds secrets | No | No |
A click, end to end
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.
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.
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.
| Platform | Capture | Input | Windows and focus | Runs as | What shaped it |
|---|---|---|---|---|---|
| Linux, Wayland | portal Screenshot, then wlr-screencopy | wlr virtual pointer and keyboard | hyprctl on Hyprland | systemd user unit | GNOME and KDE lack the wlr input protocols: capture works there, input does not yet |
| Linux, X11 | xcb | XTEST | window list only | systemd user unit | xcap reports geometry divided by DPI scale; input wants raw pixels, so rdc multiplies back |
| macOS | screencapture (about 0.3 s); CoreGraphics fallback is slow on recent macOS | CGEvent via enigo | xcap list, NSRunningApplication | LaunchAgent inside a signed rdc.app | Screen Recording and Accessibility grants are keyed to the code signature; unsigned builds lose them on every rebuild |
| Windows | GDI / Graphics Capture | SendInput normalised over the virtual desktop | xcap list, SetForegroundWindow | Task 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
inputgrant controls the keyboard. A desktop session is enough to open a shell, so rdc does not offer one separately. Grantviewbroadly andinputnarrowly. - 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 doctorreports 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.
| Route | Purpose |
|---|---|
GET /state | displays and windows |
GET /screenshot?display=all|primary|ID&format=png|jpg&max=N | image bytes; headers x-rdc-rect: x,y,w,h and x-rdc-size: W,H |
POST /act | JSON {"kind":"input", "type":"click", …} / {"kind":"focus","by":"app","value":"…"} / {"kind":"clipboard_set","text":"…"} |
GET /clipboard | {"text": …} |
GET /whoami | the caller’s resolved identity |
GET /health | ok (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
serveresolves the bind address:--bind, config, or the node’s Tailscale IPv4 from the LocalAPI. Non-Tailscale addresses are refused (except--dev-loopback).- For every request the middleware first checks the
Hostheader against the node’s own IPs, MagicDNS name, hostname and[serve].hosts; anything else is421 Misdirected Request. - It then takes the peer IP from the socket. Loopback → refused, unless
--dev-loopback. Non-Tailscale range → refused. tailscale.rscallsGET /localapi/v0/whois?addr=IPontailscaled(unix socket on Linux, loopback TCP + proof token for the macOS GUI variants, named pipe on Windows, or thetailscale whois --jsonCLI as fallback). The response gives login, node name and tags.- 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. - Each desktop route requires one capability: state and screenshot need
view,/actneedsinput(orclipboardfor clipboard writes),/clipboardneedsclipboard. Missing capability →403 forbidden./v1/whoamiand/healthneed only a valid identity. - 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
| Capture | Input | Windows / focus | Service | |
|---|---|---|---|---|
| Linux Wayland | xcap: portal Screenshot → wlr-screencopy | enigo wayland | hyprctl on Hyprland; xcap list elsewhere | systemd –user |
| Linux X11 | xcap (xcb) | enigo x11rb | xcap list | systemd –user |
| macOS | screencapture CLI (fast), xcap CoreGraphics fallback | enigo (CGEvent) | xcap list, NSRunningApplication.activate | LaunchAgent, signed .app |
| Windows | xcap (GDI/WGC) | SendInput over the virtual desktop | xcap list, SetForegroundWindow | elevated 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, orall. 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.
whoisis only as accurate astailscaled. If the local daemon is unavailable, rdc falls back to thetailscaleCLI; if neither works, every request is rejected.- The config file is the allowlist. On Unix the daemon refuses to start if
config.tomlor its directory is owned by someone else or writable by group/others, since editing it is equivalent to desktop access;RDC_INSECURE_CONFIG=1overrides with a warning. --dev-loopbackbinds 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 mcpover 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
- One seam. New capabilities go into the
Desktoptrait (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. - Coordinates are logical desktop points on the wire. Screenshots carry the rect they cover.
Platform-specific conversion happens only in
src/desktop/local/input.rsand the platform module. Never let a backend’s native units leak intoproto. - Platform code lives behind
cfg(target_os)in its own module. The common path must compile and pass clippy on Linux, macOS and Windows. Runcargo clippy --target x86_64-pc-windows-gnu --all-targets -- -D warningsand theaarch64-apple-darwinequivalent from Linux before pushing. - Fail closed. Config typos are errors. Missing identity is a rejection. Unknown capability
names are errors. Prefer returning
RdcErrorto panicking anywhere network input can reach. - No shell, no file transfer, no new listeners. rdc is a screen-and-input surface. SSH exists for everything else.
- Documentation is part of the change. Update
docs/,skills/rdc/SKILL.mdwhen it affects agents, and add a line under Unreleased inCHANGELOG.md.
Security principles
- Identity comes from
tailscaled, never from the request. The peer IP is taken from the socket;whoisresolves it. Headers, bodies and query strings carry no identity. - Tagged devices are their tags. Tailscale reports the creating user for tagged nodes; rdc discards that login. Do not reintroduce it.
- The Host header must name this machine. This blocks DNS rebinding from a browser on an allowed node. Keep the check before authorization.
- Capabilities gate every desktop route.
view,input,clipboard. Adding a route means choosing its capability and adding an audit call. - 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.
- 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.
- Bind only Tailscale addresses.
--dev-loopbackis the single exception and stays loopback-only and unauthenticated by name. - 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.
- 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.mdbefore changing anything undersrc/serverorsrc/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 serveon the developer’s machine without being asked; it exposes their desktop to the allowlist. Use--target localor--dev-loopbackfor 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:
| File | Platform |
|---|---|
rdc-linux-x86_64.tar.gz | Linux, x86_64, glibc |
rdc-macos-arm64.tar.gz | macOS 15+, Apple silicon |
rdc-windows-x86_64.zip | Windows 10/11, x86_64 |
SHA256SUMS | checksums 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
| Linux | macOS | Windows | |
|---|---|---|---|
| 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.plist | scheduled task dev.rdc.daemon.<user> |
| daemon log | journalctl --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.
| Platform | Path |
|---|---|
| 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]
| Key | Default | Meaning |
|---|---|---|
port | 7770 | TCP port for the daemon |
bind | Tailscale IPv4 | Address 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 |
audit | enabled | Audit 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):
| Capability | Allows |
|---|---|
view | displays, windows, screenshot, whoami |
input | mouse, keyboard, focus |
clipboard | reading and writing the clipboard |
all | everything (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 allow | Policy 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 rdc | policies 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:
srcnames in the policy andwhonames in rdc are the same identities: tailnet logins and tags. Node names work in rdc but not as a policysrc; 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 underhostsin 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, thenrdc -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:
local— control this machine directly, no daemon involved (default).- A name from
[targets]. - A full URL,
http://host:port. - A bare
hostorhost: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
| Variable | Effect |
|---|---|
RDC_TARGET | default for --target |
RDC_LOG | log filter, e.g. debug, rdc=debug,hyper=warn (tracing syntax) |
RDC_INSECURE_CONFIG | set to 1 to run the daemon even if config.toml is writable by others (see File permissions) |
RDC_SIGN_IDENTITY | macOS: code-signing identity name for scripts/macos/bundle-and-sign.sh (default rdc-dev) |
RDC_ALLOW_ADHOC | macOS: 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:
- The Tailscale access policy (in the admin console) decides whether a device’s packets reach port 7770 on the machine at all.
- rdc’s grants (in
config.tomlon the machine) decide what an identity that gets through may do:view,input,clipboard, orall.
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 grant | Policy src | Notes |
|---|---|---|
"alice@example.com" | alice@example.com | login, full control |
{ who = "tag:monitor", can = "view" } | tag:monitor | screenshots only |
{ who = "studio-laptop" } | a tag on that device, or a hosts entry | policies cannot name a node directly |
"tag:family" | tag:family | everyone with the tag |
"*" | whoever the policy admits | rdc 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
-
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 -
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.shThis creates a self-signed code-signing certificate named
rdc-devin 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. -
Build, bundle and sign
~/code/rdc/scripts/macos/bundle-and-sign.shProduces
~/Applications/rdc.app, signed withrdc-dev. The script refuses to fall back to ad-hoc signing unlessRDC_ALLOW_ADHOC=1, for the reason above. -
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 -
Install the LaunchAgent from inside the bundle
~/Applications/rdc.app/Contents/MacOS/rdc service installThe daemon starts, logs to
~/Library/Application Support/rdc/serve.log, and pops the two permission prompts on the Mac’s screen. -
Grant the permissions on the Mac’s console (or via a KVM). Click “Open System Settings” on each prompt and turn on
rdcunder Privacy & Security → Screen & System Audio Recording and Privacy & Security → Accessibility. Ifrdcisn’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.
-
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 doctorBoth permissions should read
granted, anddoctorshould 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 doctorreports the permission missing and the daemon log says so on restart. Click the prompt once and restart the daemon. -
Stuck grants. If Settings shows
rdcenabled 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-sourcetailscaled. Nothing to configure. -
Bundle identifier.
dev.rdc.daemonis the bundle id and LaunchAgent label, defined inscripts/macos/bundle-and-sign.shandsrc/service/mod.rs. -
Screenshots use the system
screencapturetool, 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
| Need | Mechanism | Works on |
|---|---|---|
| Screen capture | org.freedesktop.portal.Screenshot via xdg-desktop-portal, falling back to wlr-screencopy | GNOME, KDE, Hyprland, sway, river… |
| Mouse and keyboard | wlr-virtual-pointer + zwp-virtual-keyboard protocols | wlroots compositors (Hyprland, sway, river, labwc…) |
| Window list and focus | hyprctl when HYPRLAND_INSTANCE_SIGNATURE is set | Hyprland only |
| Clipboard | wlr-data-control | wlroots 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 installcreates 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:
- 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 doctorthere reports a fake 1024×768 monitor and screenshots fail.rdc service installtherefore uses a scheduled task that runs at logon as you, not a service. - It runs as a standard user by default. The task uses your normal, filtered token
(
LeastPrivilegerun 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 --elevatedcreates 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
-
Get the binary. Download
rdc-windows-x86_64.zipfrom 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 --releasePut
rdc.exesomewhere permanent, e.g.%LOCALAPPDATA%\Programs\rdc\rdc.exe. The scheduled task points at the path you install from. -
Config at
%APPDATA%\rdc\config.toml:[serve] port = 7770 allow = ["you@example.com"] -
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 -
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 --elevatedrefuses 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 statusThe 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. -
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
SendInputnormalised 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
focuson 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 inC:\ProgramData\ssh\administrators_authorized_keysfor admin accounts, and set PowerShell as the default shell viaHKLM:\SOFTWARE\OpenSSH\DefaultShell. Remember that an SSH session is session 0: use it to build and to manage the task, not to runrdc servedirectly. - Tailscale. The LocalAPI is reached over the named pipe; the
tailscale.exeCLI 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:
screenshotreturns an image (downscaled so its longer edge is at most 1568 px by default) plus a text line stating the desktop region it covers.- Every x/y the agent passes to
click,mouse_move,dragorscrollis a pixel position in the most recent screenshot. - rdc maps that pixel to a logical desktop point and sends it to the daemon.
- 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.
| Tool | Parameters | Returns |
|---|---|---|
screenshot | display (all default, primary, or id), max (px, default 1568) | image + region text |
displays | JSON list of displays | |
windows | JSON list; each window has desktop rect and, once a screenshot exists, an image rect in current screenshot pixels | |
focus | one of id, app (substring), title (substring); then_screenshot† | text (+ image) |
mouse_move | x, y; then_screenshot† | text (+ image) |
click | x, y, button (left default, right, middle), count (1–3); then_screenshot† | text (+ image) |
drag | x1, y1, x2, y2, button; then_screenshot† | text (+ image) |
scroll | optional x, y; dx, dy in wheel steps (positive = right / down); then_screenshot† | text (+ image) |
type | text; then_screenshot† | text (+ image) |
key | chord, e.g. enter, cmd+q, ctrl+shift+t; then_screenshot† | text (+ image) |
clipboard_get | clipboard text | |
clipboard_set | text | confirmation |
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 1200sends smaller images (cheaper, less detail);--max 2000the 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.
| Flag | Meaning |
|---|---|
--allow WHO[=CAPS] | identity to permit, optionally limited: --allow tag:ops=view,clipboard (repeatable, added to config) |
--port N | listen port |
--bind IP | listen address (must be a Tailscale address) |
--dev-loopback | bind 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.
| Command | What it does |
|---|---|
rdc displays | list displays as JSON: id, name, rect, scale, primary |
rdc windows | list 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 Y | move 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 TEXT | type literal text (unicode ok) |
rdc key CHORD | press a chord, see below |
rdc focus --id ID | --app SUBSTR | --title SUBSTR | bring a window forward |
rdc clip [TEXT] | read the clipboard, or set it |
rdc whoami | ask 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.
| Modifiers | cmd command super win meta (all = the platform’s Meta key), ctrl control, alt option, shift |
|---|---|
| Named keys | enter/return, esc/escape, tab, space, backspace, delete, insert, home, end, pageup, pagedown, up down left right, capslock, f1…f24 |
| Punctuation names | plus minus comma period slash backslash semicolon quote grave equal bracketleft bracketright |
| Anything else | a 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
| Message | Meaning | Fix |
|---|---|---|
allowlist is empty | no --allow and no [serve].allow | add at least one entry |
X is not a Tailscale address; refusing | --bind or config points at a LAN/public IP | bind the Tailscale IP or leave bind unset |
tailscaled unreachable / no Tailscale IP | Tailscale not running or not logged in | tailscale status; on macOS make sure the Tailscale app is running in the same user session |
bind … Address already in use | another rdc or something else on 7770 | --port |
Client can’t connect
| Symptom | Cause | Fix |
|---|---|---|
| connection refused / timeout | daemon not running, wrong host, you’re not on the tailnet, or the Tailscale policy blocks tcp/7770 | tailscale ping HOST; check rdc service status on the target; add a policy rule for tcp:7770 (see Configuration) |
403 … is not in the allowlist | whois succeeded but you’re not allowed | add 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 address | request arrived from a non-tailnet IP | use the Tailscale hostname or 100.x address |
403 forbidden: … may not use \input`` | your grant is limited to some capabilities | widen can in the grant on the daemon side; rdc -t HOST whoami shows your caps |
403 loopback connections are not accepted | you’re on the same machine | use --target local, or start the daemon with --dev-loopback for testing |
421 request addressed to unexpected host | the URL uses a name the daemon doesn’t recognise as itself | use the Tailscale MagicDNS name or IP, or add the name to [serve].hosts |
400 point (…) is outside the desktop | coordinates beyond the displays | check rdc displays; with MCP, take a fresh screenshot |
400 scroll steps must be within ±100 | scroll amount too large | scroll in smaller steps |
Screenshots
| Symptom | Cause | Fix |
|---|---|---|
macOS: image is only the wallpaper, windows lists 1 entry | Screen Recording not granted to this exact build | macOS setup: re-grant, tccutil reset, avoid ad-hoc signing |
| macOS: screenshots take many seconds | screencapture failed and rdc fell back to CoreGraphics | check the daemon log for screencapture failed; usually a permission problem |
Wayland: xcap: … error | no portal backend or screencopy support | install xdg-desktop-portal-<compositor>; on GNOME the portal Screenshot dialog may need approving once |
| black image on X11 | compositor/driver quirk | try --display primary |
Input
| Symptom | Cause | Fix |
|---|---|---|
macOS: permission denied: Accessibility or clicks do nothing | Accessibility not granted | grant to rdc.app, restart daemon |
Wayland: no way to move the mouse | compositor lacks the wlr virtual pointer protocol (GNOME, KDE) | not supported yet; capture works, input does not (issue #2) |
| clicks land in the wrong place | coordinates from a stale or differently sized screenshot; or CLI given image pixels instead of desktop points | take a new screenshot; use rdc mcp, or convert as in CLI reference |
key: "foo" is not a modifier | typo in the chord | see the chord grammar in the CLI reference |
| Windows: input ignored or focus refused for an elevated app | UIPI; the daemon runs at standard integrity by default | close 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 fail | you’re in an SSH or service session (session 0) | run the daemon via rdc service install; test from the client machine |
MCP
| Symptom | Fix |
|---|---|
| Claude Code shows the server as failed | run 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/slow | rdc mcp --max 1200 |
the agent clicks the wrong thing after a screenshot with a custom max | the skill tells agents to click from the latest image; remind it |
Logs
- Daemon:
RDC_LOG=debug rdc serve …(foreground); service logs are injournalctl --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 100on the daemon machine. - Client and MCP:
--log debugorRDC_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
| Path | Contents |
|---|---|
src/proto.rs | wire and domain types shared by daemon, client, CLI and MCP |
src/desktop/mod.rs | the Desktop trait |
src/desktop/local/ | in-process implementation: capture, input worker, per-platform window code |
src/desktop/remote.rs | HTTP client implementation |
src/server/ | axum daemon, whois auth middleware, routes |
src/tailscale.rs | LocalAPI discovery per platform, CLI fallback |
src/mcp.rs, src/view.rs | MCP tools and pixel↔point mapping |
src/keys.rs | key chord grammar |
src/service/ | LaunchAgent and systemd installers |
src/doctor.rs, src/permissions.rs | readiness 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 warningsandcargo testpass 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
Desktoptrait 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
RdcErrorover 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, afterrustup target add x86_64-pc-windows-gnu aarch64-apple-darwin, runcargo clippy --target <triple> --all-targets -- -D warningsfor 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 installnow registers the logon task atLeastPrivilegerun 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; passrdc service install --elevated(from an elevated PowerShell) to get the previousHighestAvailablebehaviour. 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 (
grantsandaclsforms) 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 serveandrdc service installrefuse to run whenconfig.tomlor its directory is owned by another user or writable by group/others, because editing the allowlist is equivalent to desktop access.rdc doctorreports the check asconfig perms;RDC_INSECURE_CONFIG=1downgrades the refusal to a warning. - Windows support, verified on Windows 11.
rdc service installcreates an elevated Task Scheduler logon task that runs the daemon in the interactive session with a log file;service uninstallstops the running daemon. Windowfocusimplemented (foreground-thread attach with an Alt-tap fallback). Absolute pointer moves useSendInputover the whole virtual desktop so secondary monitors are addressable (untested on real hardware yet). --log-file/RDC_LOG_FILEto 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,inputand/orclipboard. Config accepts plain strings (full control), inline tables{ who = "...", can = [...] }insideallow, or[[serve.grant]]blocks; the CLI accepts--allow who=view,clipboard. Missing capability →403 forbidden.whoamireportscaps. - 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]. Newrdc auditcommand to read it. rdc doctorprints the configured grants and the audit log path.
Changed
--allowand[serve].allowentries 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.