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.shTo 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:
| Situation | Result |
|---|---|
Relay root (localhost:7080) | 404 |
| Unknown tunnel, or the agent stopped | 502 tunnel demo.dev is not connected |
| Local server stopped | 502 could not reach localhost:8080 |
| Wrong token | Agent exits with relay rejected agent: invalid token |
| Relay restarts | Agent reconnects with backoff (1s, 2s, 4s … up to 30s) and gets the same URL back |
lnk tunnel open 8080 --name demo --auth friend:secret | A login prompt, and curl -u friend:secret … gets through. |
lnk tunnel open 8080 --github <you> on a relay without GitHub login | Refused: the relay has no GitHub client secret. github_visitors.rs tests it with a fake GitHub. |
/ws on the demo server | WebSockets 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_serviceruns the demo server on a random port.start_relaystarts a relay with usersdanaandalice, andstart_relay_with(|config| ...)changes its settings.start_agent(relay, port, Some("id"))starts an agent, andstart_agent_with(relay, service, id)does it for anyService.connect(agent_config(relay, token, name), service)takes any settings and returns the relay's refusal.fetch(relay, host, path)sends a request, andhost_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>;tosrc/tests/e2e/tests/main.rsand reach the helpers withuse crate::common;(why). - A test that runs
lnkor a plugin gets them fromlink_plugin::testing::programs(), every program of the workspace, built (why), and one from a folder beside the core fromprograms_in. - A first test in a plugin's
main.rsneeds the plugin'stest = falseremoved from itsCargo.toml, andtooling.rssays 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
Dropor by aremove_dir_allas 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. PassNO_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.shsigns the aws and gcloud CLIs in from the environment'sAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,GCP_SA_KEY(a service account's JSON key) andGCP_PROJECT, solnk cloud connectfinds a login. It does this only whenCLAUDE_CODE_REMOTE=true, and does nothing on your own machine. The environment's setup script installs the CLIs. The container allows no SSH out, solnk boxcommands 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.shsetsCARGO_INCREMENTAL=0for the session. A fullcargo testthen writes 3 GB instead of 5.6, and a rebuild after an edit takes about 35 s instead of 15.cargo cleanfreestarget/whole. The folders beside the core build into one sharedtarget-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.shsetsCARGO_BUILD_JOBSto 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 -1234signals 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 withlink_plugin::testing::kill_group, andtooling.rsfails 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'segress.rsandup.rs) print "skipping" and pass, testing nothing. In Claude Code on the web,.claude/hooks/sandbox-tools.shinstalls bubblewrap and OpenSSH's server (forguest.rs) at the start of each session and setsLNK_REQUIRE_SANDBOX=1, so those tests fail rather than skip. Elsewhere, runapt-get install -y bubblewrap openssh-serverand set the variable yourself. - Timing tests.
cargo test -- -Z unstable-options --report-timeneedsRUSTC_BOOTSTRAP=1, which cargo counts as a change: the end-to-end tests' build oflnkand its plugins (common::programs()) then recompiles them, about 3 minutes, and the next plaincargo testrecompiles them back. Set it for acargo buildfirst, or time withtimeinstead. - Killing processes. Don't use
pkill -fwith a pattern that also appears in your own command line, because it kills the shell running the command (exit 144). Find the PID withps -eo pid,args | grep 'target/debug/link-relay'andkillthat PID. - Backtraces.
RUST_BACKTRACE=1is set here, so a normalanyhowerror 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 withlink_plugin::testing::write_programorcopy_program, neverfs::writeand 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 aVecbefore awaiting, or the requests run one at a time. - Python test servers.
http.serverhas 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.
shellcheckthem, with the list in Before you push.- For
files/link-relay-update, serve a fake release directory withpython3 -m http.server, then pointLINK_RELEASES_URL,LINK_RELAY_BINandLINK_UPDATE_STATEat scratch paths. Put a stubsystemctlfirst onPATHwhoserestart link-relaystarts the binary on :7080. This covers install, no-op, checksum failure and rollback, andsrc/tests/e2e/tests/updater.rsdoes some of it. - For
files/link-state, run it withLINK_STATE_ROOT=<dir> LINK_NO_RESTART=1. - For
create-droplet.shandrebuild.sh, stubdoctl,sshandscponPATHand check the calls they make.
Decisions says why it works this way.
