Get Started

Run the relay and a tunnel on one machine, write a test for them, and avoid the traps that catch most people here. The checks to run before pushing are in Developing.

Run it locally

You need Rust and curl. Rustup installs the version the repository pins, from its rust-toolchain.toml, at your first cargo. The checks before a push also need shellcheck, and on Linux the sandbox's tests need bubblewrap and openssh-server. The quickest way is the demo:

# builds, starts the demo server, relay and agent, then curls /, /stream and /echo
./src/demos/demo.sh

To run the pieces by hand, start each in its own terminal, or in the background with logs in a temp folder. With no users file, the relay runs in local dev mode, with a single user, dev, and a built-in token that the agent uses by default. Local URLs then look like <app>.dev.localhost:7080. A real deployment uses a users file, and the relay refuses to start without one unless it runs on localhost.

export NO_PROXY='*'
# `lnk` runs its plugins (lnk-tunnel, ...) from target/debug, so build them all
cargo build
# 1. an app to expose, on localhost:8080 (any web server works, e.g. `next dev -p 8080`)
./target/debug/link-demo-hello-server
# 2. the relay, on 127.0.0.1:7080 (in production this runs on a server)
./target/debug/link-relay
# 3. the agent; it uses https://local.link unless told otherwise
export LNK_RELAY=http://localhost:7080
./target/debug/lnk tunnel open 8080 --public --name demo   # without --name, a random app name
# 4. the internet (well, you)
curl http://demo.dev.localhost:7080/
# chunks arrive as they are produced
curl -N http://demo.dev.localhost:7080/stream

*.localhost resolves to 127.0.0.1 in curl and in browsers. If a tool doesn't resolve it, send the Host header yourself with curl -H 'Host: demo.dev.localhost:7080' http://127.0.0.1:7080/. With an HTTP proxy, add .localhost to NO_PROXY.

Here is what to expect, and what to try:

SituationResult
Relay root (localhost:7080)404
Unknown tunnel, or the agent stopped502 tunnel demo.dev is not connected
Local server stopped502 could not reach localhost:8080
Wrong tokenAgent exits with relay rejected agent: invalid token
Relay restartsAgent reconnects with backoff (1s, 2s, 4s … up to 30s) and gets the same URL back
lnk tunnel open 8080 --name demo --auth friend:secretA login prompt, and curl -u friend:secret … gets through.
lnk tunnel open 8080 --github <you> on a relay without GitHub loginRefused: the relay has no GitHub client secret. github_visitors.rs tests it with a fake GitHub.
/ws on the demo serverWebSockets pass through (it echoes every message), so dev-server hot reload works
A model (models)With a local relay the URL is http://<app>.dev.localhost:7080/v1

The demo server (link-demo-hello-server, on :8080) answers /, /echo, /headers, /stream and /ws (a WebSocket echo).

Write a test

Test new behavior in src/tests/e2e/tests/tunnel.rs, with the helpers every end-to-end file shares (src/tests/e2e/tests/common/relay.rs):

  • start_local_service runs the demo server on a random port.
  • start_relay starts a relay with users dana and alice, and start_relay_with(|config| ...) changes its settings.
  • start_agent(relay, port, Some("id")) starts an agent, and start_agent_with(relay, service, id) does it for any Service. connect(agent_config(relay, token, name), service) takes any settings and returns the relay's refusal.
  • fetch(relay, host, path) sends a request, and host_of(url) gives a tunnel's host.

Unit tests live next to the code in each crate. The end-to-end tests send requests to 127.0.0.1 with a Host: <app>.<user>.localhost:<port> header, so they need no DNS. A few rules keep them working:

  • A new end-to-end file is a module of one test program. Add mod <file>; to src/tests/e2e/tests/main.rs and reach the helpers with use crate::common; (why).
  • A test that runs lnk or a plugin gets them from link_plugin::testing::programs(), every program of the workspace, built (why), and one from a folder beside the core from programs_in.
  • A first test in a plugin's main.rs needs the plugin's test = false removed from its Cargo.toml, and tooling.rs says which.
  • A test that runs cargo uses link_plugin::testing::cargo(), because any other way recompiles the workspace (why).
  • A test's temporary folder is removed when the test ends, by a Drop or by a remove_dir_all as its last line.

Gotchas

In Claude Code on the web, running out of memory restarts the whole machine without saying why, so read that one first. The rest are smaller surprises.

  • Proxy. This environment sets HTTP(S)_PROXY. Pass NO_PROXY='*' to curl. In code, every reqwest client must call .no_proxy(), and the agent and the tests already do.
  • Cloud logins in Claude Code on the web. At the start of each session .claude/hooks/cloud-logins.sh signs the aws and gcloud CLIs in from the environment's AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, GCP_SA_KEY (a service account's JSON key) and GCP_PROJECT, so lnk cloud connect finds a login. It does this only when CLAUDE_CODE_REMOTE=true, and does nothing on your own machine. The environment's setup script installs the CLIs. The container allows no SSH out, so lnk box commands that log in to a box don't work there.
  • Disk in Claude Code on the web. The disk is a fixed allowance, so .claude/hooks/build-settings.sh sets CARGO_INCREMENTAL=0 for the session. A full cargo test then writes 3 GB instead of 5.6, and a rebuild after an edit takes about 35 s instead of 15. cargo clean frees target/ whole. The folders beside the core build into one shared target-beside/ at the root (why).
  • Memory in Claude Code on the web. The session is a virtual machine, so when its memory runs out the whole machine restarts, with every process and agent on it, and nothing inside says why. A cold link-core build peaks at 3.7 GB, and each git worktree, like each folder beside the core, builds from cold in a target/ of its own. Four agents building in four worktrees passed 15 GB and restarted the machine again and again. build-settings.sh sets CARGO_BUILD_JOBS to about one job per 4 GB and says so at the start of each session. Run one cold build at a time.
  • A restart that isn't memory. The machine also restarts when its init process is killed, and a test running as root can kill it. procps-ng's kill -KILL -1234 signals group -1, every process, so a test that killed a harness's group that way restarted the machine whenever the harness's process id began with 1, as ids do in the first minutes after a start (Oct 8 2026, at 0.8 GB of 15 used). Tests kill a group with link_plugin::testing::kill_group, and tooling.rs fails on any other way. A machine that restarted with little memory in use was killed, so look for what signals -1.
  • Bubblewrap. Without bwrap, the sandbox's tests (tests/proxy.rs, run.rs, guest.rs, and the harness's egress.rs and up.rs) print "skipping" and pass, testing nothing. In Claude Code on the web, .claude/hooks/sandbox-tools.sh installs bubblewrap and OpenSSH's server (for guest.rs) at the start of each session and sets LNK_REQUIRE_SANDBOX=1, so those tests fail rather than skip. Elsewhere, run apt-get install -y bubblewrap openssh-server and set the variable yourself.
  • Timing tests. cargo test -- -Z unstable-options --report-time needs RUSTC_BOOTSTRAP=1, which cargo counts as a change: the end-to-end tests' build of lnk and its plugins (common::programs()) then recompiles them, about 3 minutes, and the next plain cargo test recompiles them back. Set it for a cargo build first, or time with time instead.
  • Killing processes. Don't use pkill -f with a pattern that also appears in your own command line, because it kills the shell running the command (exit 144). Find the PID with ps -eo pid,args | grep 'target/debug/link-relay' and kill that PID.
  • Backtraces. RUST_BACKTRACE=1 is set here, so a normal anyhow error exit prints a stack trace. That is not a panic.
  • Tests that write a program and run it, such as a fake adapter or a fake lnk, write it with link_plugin::testing::write_program or copy_program, never fs::write and a chmod. Tests run side by side in one process, and a child another test forks while the program is open for writing keeps it open until it runs its own program. Linux then refuses to run the program ("Text file busy", ETXTBSY), a failure that comes and goes under load. The helper has a child process (install) write the file, so the test's process never holds it open.
  • Concurrency tests. (0..n).map(|_| tokio::spawn(..)) is lazy. Collect into a Vec before awaiting, or the requests run one at a time.
  • Python test servers. http.server has a small listen backlog, so bursts of more than about 5 connections show latency that is not Link's. Use the demo server instead.

Test the server deploy scripts

The scripts in src/server/deploy (Run the Relay) can't run fully here, since there's no systemd, apt or DigitalOcean, but most of their logic can.

  • shellcheck them, with the list in Before you push.
  • For files/link-relay-update, serve a fake release directory with python3 -m http.server, then point LINK_RELEASES_URL, LINK_RELAY_BIN and LINK_UPDATE_STATE at scratch paths. Put a stub systemctl first on PATH whose restart link-relay starts the binary on :7080. This covers install, no-op, checksum failure and rollback, and src/tests/e2e/tests/updater.rs does some of it.
  • For files/link-state, run it with LINK_STATE_ROOT=<dir> LINK_NO_RESTART=1.
  • For create-droplet.sh and rebuild.sh, stub doctl, ssh and scp on PATH and check the calls they make.

Decisions says why it works this way.