arc

Agent remote control. Let an AI agent on one machine (e.g. Claude Code on
macOS) drive another machine (e.g. Windows) over an encrypted link — build and
run apps, operate GUIs, move files — through semantic, structured tools, not
a pixel stream.
It is not a remote desktop: there's no screen-sharing for humans. The agent
calls tools — run a command, list/click UI elements, type, screenshot, transfer
files — over an end-to-end-encrypted channel. Part of
agent-rt (rt = runtime).
Install
Controller (macOS / Linux) — the arc CLI, which is also the MCP server:
brew install agent-rt/tap/arc
Runner (Windows) — the machine being driven:
winget install agent-rt.arc-runner
Quickstart
Over Tailscale (recommended — no relay, no pairing code to copy):
-
On the Windows runner, register it (autostarts at logon). On Tailscale
it's one flag — --tailscale auto-detects the tailnet IP and restricts
access to this machine's Tailscale owner:
arc-runner install --tailscale
(Equivalent to --listen :8787 --trust-tailnet --allow ,
still passable explicitly; --allow-any permits any tailnet peer, --port
changes the port, --dry-run previews.) It mints credentials and prints a
ready-to-paste [targets.win] block.
-
On the controller, drop that block into ~/.config/arc/config.toml:
default = "win"
[targets.win]
direct = ":8787"
trust_tailnet = true
-
Drive it:
arc -t win shell --cmd 'ver'
arc-runner uninstall removes the task; arc-runner upgrade self-updates it to
the latest release (download → validate → swap → restart). For relay mode
instead of Tailscale, use arc-runner install --relay .
> Coming from ssh + scp? See docs/from-ssh.md for a
> side-by-side migration of the common workflow.
Usage
arc shell --cmd 'dotnet build' # run a command (streams live; --timeout )
arc run ./fix.ps1 -Port 8788 # ship & run a local script (.ps1/.bat); args pass through
arc push ./app C:/work/app # send a tree up (incremental, .gitignore-aware)
arc watch ./app C:/work/app --on-change 'cargo build' # auto-push + rebuild on every save
arc pull C:/work/app/bin ./bin # fetch a tree (or file) back
arc cat C:/work/app/log.txt # print a remote file
arc tail C:/work/app/log.txt -f # follow a remote log (streams new lines)
arc shot ui.png --launch notepad # launch → wait for render → screenshot, one shot
arc screencap shot.png --window N # screenshot to a file (.png/.webp by extension)
arc screencap now.png --window N --baseline before.png --diff diff.png # regression diff
arc windows --filter notepad # list top-level windows (--json for structured)
arc elements --json # list a window's UI Automation elements
arc find --type Button --name Save # query elements by attribute (no full dump)
arc wait --name Done --timeout 30 # block until a matching element appears
arc open notepad # launch an app
arc activate # restore + foreground a window (before capture/input)
arc ps notepad # list remote processes; arc kill
arc click # click a UI element (from `elements`)
arc read # read one control's text (verify without a screenshot)
arc set 'text' # set a control's value directly
arc type 'hello' --into # focus an element, then type into it
arc type "$(cat big.txt)" --into --paste # paste long text via clipboard (fast)
arc key ctrl+a delete --into # key chords in sequence (--into focuses an element first)
arc mouse drag 40 80 300 400 # move / click / down / up / scroll / drag
arc clip get # read the remote clipboard; arc clip set 'text'
arc agents-md # full Markdown command reference for an AI agent
Connecting
arc finds a runner via a saved named target (-t , or the config's
default), explicit flags (--direct / --relay / --session / --pairing),
or env vars (ARC_DIRECT / ARC_RELAY_URL / ARC_SESSION / ARC_PAIRING).
Config lives in ~/.config/arc/config.toml:
default = "win"
[targets.win] # Tailscale-direct, identity-authenticated
direct = "100.x.y.z:8787"
trust_tailnet = true
[targets.public] # over a relay
relay = "wss://relay.example/v1/relay"
session = "0011…ffff"
pairing = "XXXX-XXXX"
Files & the dev loop
push/pull are direction-by-verb (controller→runner / runner→controller). A
single file is copied wholesale; a directory transfers incrementally —
content-hashed with one round-trip to skip unchanged files, never walking build
dirs (target/bin/obj/…). Flags: --delete (mirror the source),
--dry-run, --whole (skip the diff). arc watch keeps a tree mirrored live
(debounced, build-churn-ignoring); add --on-change '' to rebuild on the
box after every sync — a zero-ssh edit → build → run → screenshot loop. To run a
local script on the box,
arc run ./script.ps1 [args] ships its contents and executes it (interpreter by
extension; .ps1 runs with -ExecutionPolicy Bypass) — no push first, no
cross-shell quoting to escape, args passed straight through.
> The model is code lives on your Mac; the Windows box is a build/run
> container. With Windows App SDK 2.x an unpackaged WinUI 3 app builds with
> just the .NET SDK (no Visual Studio), so a fresh runner + .NET SDK builds WPF /
> WinForms / console / WinUI 3.
MCP
The same arc binary runs as a stdio MCP server with arc --mcp, exposing every
capability as a tool (run_command, run_script, screenshot, list_windows,
list_elements, find_elements, click, type_text, set_value, read_file,
write_file, …). Register it with, e.g.,
mcpctl:
mcpctl server add arc --command arc --args --mcp \
--env ARC_DIRECT=:8787 --env ARC_PAIRING=XXXX-XXXX
It connects lazily on the first tool call and reconnects on drops. run_command
streams output (relayed as MCP progress when the client supplies a
progressToken), so an agent can watch a long build in real time.
Modes
The relay exists to solve NAT traversal; on a shared private network it's
unnecessary.
- Direct (recommended on Tailscale / LAN): the runner listens
(
arc-runner --listen :8787) and the controller dials it — no relay, no
extra process, lower latency.
- Relay: both peers dial an
arc-relay, which matches them by SessionId
and forwards opaque ciphertext (it holds no key).
- Trusted-tailnet auto-pairing (
trust_tailnet = true): the runner
authenticates each caller by verified Tailscale identity (tailscale whois, optionally restricted to allow_logins), so no pairing code is
exchanged — authentication is the WireGuard transport plus identity, with a
fixed public key for the (defense-in-depth) Noise channel.
Architecture
controller (macOS/Linux) relay (optional) runner (Windows)
┌────────────────────┐ wss ┌──────────────┐ wss ┌──────────────────────┐
│ arc / arc --mcp │──────►│ arc-relay │──────►│ arc-runner │
│ (CLI / MCP server) │◄──────│ opaque relay │◄──────│ shell·UIA·input· │
└────────────────────┘ │ (no key) │ │ screenshot·files │
└────────── L2: Noise channel, end-to-end encrypted ──────────┘
- L1 — relay (
proto::relay): Hello + SessionId + Role, then opaque
Relay{data} frames forwarded between the session's two peers. Zero-knowledge.
Skipped entirely in direct mode.
- L2 — end-to-end (
proto::wire): Request / Response / Event carrying
Commands, sealed in a Noise channel (proto::crypto) and chunked through the
32 MiB frame cap.
Both transports share one Session (arc-net), used by the CLI, the MCP
server, and the runner.
Capability tiers (by session state)
| Capability | Mechanism | In a detached session? |
|---|
list_*, set, element click | UI Automation (COM) | ✅ works |
single-window screencap | GDI PrintWindow | ✅ works |
type, key, coordinate mouse, full-screen screencap | SendInput / DXGI | ⚠️ needs an active console/RDP session |
UIA pattern-based control works headlessly; raw input and full-screen capture
need a connected desktop. ElementId is ":", resolved by
RuntimeId identity — it keeps pointing at the same control across tree changes,
or returns NotFound.
The runner serves requests concurrently (a hung command never blocks the
link or other commands) and enforces a default 10-minute command deadline
(--timeout ; 0 to disable).
Security
The relay sees only ciphertext. The low-entropy pairing code is never used as a
key directly: both peers run a symmetric SPAKE2 PAKE to derive a
high-entropy key that keys Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s. Recording
the handshake gives no offline dictionary attack; a wrong code makes the Noise
handshake fail. In trust_tailnet mode the boundary is Tailscale ACLs plus
verified identity.
The Windows binaries are code-signed — see the code signing
policy (free certificate courtesy of the
SignPath Foundation).
Build from source
just build # release `arc` (CLI + MCP server)
just lint # clippy -D warnings (host crates)
just test
| Crate | Role |
|---|
arc-proto | wire protocol, CBOR framing, Noise channel (chunked), SPAKE2 |
arc-net | shared client transport — relay/direct Session |
arc-relay | zero-knowledge wss relay (axum) |
arc-runner | Windows runner: shell, screenshot, UI Automation (windows-rs), input (enigo) — Windows-only |
arc-cli | the arc binary: CLI and arc --mcp server |
arc-runner builds only on Windows (windows-rs, xcap); the controller
crates build everywhere. Release/packaging is in RELEASING.md.
License
Licensed under either of MIT or Apache-2.0 at
your option.