Plugins
Add a plugin, a harness, a memory engine, a channel or a cloud to Link, with its code in the plugin it belongs to.
Why lnk is a core that runs plugins, and the rules that follow, are in
The core and its plugins.
Mechanically, lnk tunnel open ... runs lnk-tunnel tunnel open ...
from lnk's own folder, or else the PATH, with LNK_EXE set to the
core. Plugins use each other only by running lnk <command>
(link_plugin::lnk()) and reading a documented output, and Plugin
Contracts lists every one.
Changing an output changes an interface, so keep old fields and add new
ones.
Where code goes: the seam test
Code belongs in the plugin whose purpose it serves, and a plugin's
purpose is its name. tunnel gives public URLs, whatsapp talks to
WhatsApp, and a harness's adapter runs that harness. Before adding code to
plugin P, ask:
- Would I still use this code without installing the plugin it serves? If code in P serves Q's purpose, everyone who installs P carries Q's code. If Q needs it, installing Q drags in P, a plugin the user may not want. Then the code goes in Q, or in a plugin of its own when several plugins need it and none owns it.
- Does P have to know another plugin's files, API or quirks? Then P
breaks when that plugin changes, and can't work with one from outside
Link. P asks the other plugin through a documented
lnk <command> --jsoninstead (Plugin Contracts), or the other plugin hands P what it needs as data. - Would a developer look for it here? "Where does code for X and Y go?" must have one answer.
The same holds for the shared library (link-plugin), which every plugin
links, so nothing in it serves one plugin. It also holds for the core,
which knows plugins only by name, what they need, and how to run them.
The login, worked through. lnk auth login was the tunnel plugin's.
Boxes need the login, to tag each box as yours, and a box user who never
opens a tunnel still uses the login code. So it failed test 1 in the
tunnel, because boxes needed tunnels. Tunnels, boxes and share links all
use the login and none owns it, so it is its own plugin, accounts, and
the others ask lnk auth ... --json.
An outside harness and WhatsApp, worked through. Say a harness from outside Link speaks WhatsApp itself. The code setting up its own WhatsApp runs only for that harness, and only its adapter knows it, so it goes in that adapter, in its own repository. The WhatsApp plugin carries messages for any harness that speaks no WhatsApp, and never names a harness. So code for "that harness and WhatsApp" goes in its adapter, and code for "WhatsApp for any harness" in the WhatsApp plugin. Code that knows WhatsApp but sits in neither, such as the harness plugin or the sandbox, fails the test.
A command's help
Every command's --help, the core's and its plugins', is in one file,
src/local/help.toml, and never in doc comments. A command's table is at
the words you type without lnk, and a plugin's own is at its program's
name. Edit help there, in the PR that changes the command.
[agent.start]
about = """
Start your agent (Link harness by default), in the background
The first line is the short help; the rest shows with --help only.
"""
"--model" = "The model: a local one by name (qwen3), or one the provider names."
"<name>" = "A positional argument, by its field's name."
[agent."<harness>".deny] # lnk agent <harness> deny
[lnk-models] # lnk-models --help, the program itself
[lnk-aws.connect] # lnk-aws connect, which only lnk runsaboutis the command's help andafteris what follows its options. An argument's help is at"--<long>", at"-<short>"when it has no long, or at"<field>"for a positional.- A text is read as clap reads a doc comment. Paragraphs are split by a
blank line and lines are joined. The first paragraph is the short help
(
-h, and the line a group's list shows), without its last period. - A new command or flag needs its entry.
cargo test -p link-e2e --test e2e help::fails on a command or argument with none, on help left in a doc comment, and on an entry naming nothing. A new plugin'sclinames where its help is (link_plugin::help::Help), itsmainparses with it (HELP.parse()), and the plugin goes in that test's list. - Link Harness, Link's tools and each harness adapter have no clap. They
keep their own
help.tomlat their folder's root, with an entry per command they answer, which their--helplists and a test checks against theirmain.
Adding a plugin
A plugin is a crate with a binary, registered in a handful of lists so that the core, the release and the tests know it.
- Create
src/local/plugins/<name>, the cratelink-plugin-<name>. It is a lib and anlnk-<name>binary whose clap parser is namedlnk, with its group as the top subcommand (lnk-harness agent start), so its help readslnk agent start. Each command's arguments are one type in the lib'sclimodule, listed insrc/tests/e2e/tests/flags.rs(Specs and Command Lines). It may depend onlink-pluginandlink-agent, never on another plugin. - Register it:
- In the root
Cargo.toml, undermembers, and under[workspace.dependencies]when another crate depends on it. - In
src/local/cli/src/main.rs, add its group toGROUPS, its commands toCOMMANDSand it toPLUGINS. Add aUSESentry iflnk upshould offer it, andneeds()if it's no use alone. - In
PLUGINSin.github/workflows/release.yml, from which the release buildslink-plugin-<name>and packages it.src/tests/e2e/tests/release.rschecks the two lists match. src/tests/e2e/tests/help.rsfails untilCOMMANDSsays what its--helpsays.
- In the root
- To make it exposable, give it a
serve <target> --jsoncommand likelnk model servein the models plugin, and have the tunnel run it forlnk tunnel open <target>. - Put any output another plugin reads in Plugin
Contracts. If it keeps files
or runs a service, it answers
uninstall(link_plugin::uninstall) with what it keeps, by part, and how to stop and delete it, solnk uninstallandlnk plugin removenever name it. - Test its logic through its lib, and end to end with a relay, an agent
and a
PortServicepointed at what it serves, assrc/tests/e2e/tests/llm.rsdoes. - Write its page,
docs/en/<part>/README.mdandsecurity.md, a line indocs/en/README.md, its settings indocs/en/CONFIGURATION.md, its crate in the install from a branch in Install lnk (release.rschecks it), and its web page.
Adding a harness
A harness is a plugin of its own, in its own repository. It is an
lnk-<name> program answering the adapter
commands:
contract, install, configure, command, health, hosts,
traces, state, channels, link, needs, and endpoint for one that
speaks no channel. The contract is link_plugin::harness, a git
dependency on this repository's link-plugin.
Link ships Link Harness, and builds and ships the OpenClaw, Hermes and
DeepSeek Harness adapters too (link-openclaw, link-hermes,
link-deepseek), each at the core's version. They are in PLUGINS in the
core and KNOWN in the harness settings. Link's core and harness plugin
name another project's harness only in those two lists, and a test keeps
it so.
A harness that answers chat completions needs no channel code. Its
endpoint names where, and Link carries the channels to it, as
lnk-link-harness shows. The endpoint answers GET /_link/proof with
link_plugin::harness::endpoint_proof, and the contract says proof, so
Link's channels send its key only to a connection that proves it's the
harness.
An adapter follows these rules:
- It installs and keeps everything under
link_plugin::home_dir(). It runs withHOMEset to its home, in the sandbox, so it can write nowhere else. - It installs the harness with the harness's own installer, pinned to a
release with
link_plugin::harness::run_installer. That runs the script from the project's repository at the release's commit, checked against its SHA-256, and the adapters'VERSIONcomments say how to move to a new release. It configures the harness with its ownconfig set. - It puts secrets in the harness's own files with
merge_env_file, never inLaunch.env, which ends up in a service definition other local users can read. - It answers
contractwithlink_plugin::harness::Contract::new(proxy), andproxyis true only when a test shows the harness reaches its model atmodel.base_url. - It is released from its own repository, signed with its own key, and
users add it with
lnk plugin add <its URL>, as adapters from outside Link says.
src/local/plugins/harness/tests/up.rs drives a fake adapter script.
Every harness's tests also run the conformance kit
(src/tests/conformance, link_harness_conformance::check), which runs
its adapter as lnk-harness would and checks its endpoint. It checks the
key and proof, that threads are kept apart, that a turn whose caller goes
is still kept, and a call to the kit's own tool through the bridge. A
harness that takes local models only through Ollama is checked with
Check::new(adapter).provider("ollama"). The core's own fixture harness,
lnk-mini in src/tests/fixtures/mini-harness, passes the kit, and the
core's end-to-end tests run on it.
Link Harness's repository runs the kit, and each adapter's runs it beside
its test on the wire (tests/on_the_wire.rs) against the real harness
behind LNK_TEST_REAL. An outside adapter runs both the same way, with
link-core beside it.
A harness needs nothing of its own for the sandbox. The harness plugin
runs every harness in the same one, with its home, Launch.write,
Launch.read and its permissions. What it needs beyond Link's defaults,
such as lan or no sandbox at all, it says in needs, with why, and the
user is asked before it runs. Change the harness to say it, never to fit
the sandbox.
Adding a memory engine
A memory engine is a plugin that serves an MCP server for an agent's
folder. Link's are in Link's tools repository, link-tools, and one from
outside Link lives in its own. It answers install and command <folder> <state>. The contract is link_plugin::memory, and the output is
Serve.
command prints how to serve an MCP server over stdio for the folder,
keeping its index or database in <state>, which is also its HOME. The
engine runs confined. It reads the folder and its own program and nothing
beside them, and it writes only <state>, so an engine that installs
anything puts it there. Three settings shape the rest:
- Name the calls that only read in
allow, so they don't ask. - Set
modelsonly if the engine calls a model on this machine. - Name an embedding model in
modelto have Link serve it (lnk model serve) outside the sandbox while the engine runs, at$LNK_MEMORY_MODEL, which is empty when no runtime here has it.
Then add a line to MEMORIES in the harness settings, with the name you
type, its plugin and what it is. For a plugin of Link's, add it to
PLUGINS and needs() in the core as well. lnk-link-memory, in Link's
tools repository, is the example. Its tests/agent.rs drives it through
lnk agent memory use and searches it from the agent's sandbox.
Adding a channel
A channel is a plugin that lets people text an agent, through lnk agent connect <channel>. It lives in src/local/plugins/<channel> as
lnk-<channel>, built on link_plugin::channel, and its main takes the
channel's About. It answers three commands:
connectasks for what the channel needs, refuses a bot another agent has (check_taken), pairs the owner with a one-time code (pairing_code), and prints the channel's settings and its bot. The bot'sidis what another agent'sconnectrefuses.lnk-telegram,lnk-slackandlnk-discordare the examples.describe --jsongives its title, which of its settings are secret (a Mac keeps them in the Keychain), its hosts, and what disconnecting leaves. The harness plugin keeps this with the channel, so it names no channel.rule --jsonsays how a harness speaking the channel reaches the service with a placeholder while the proxy adds its secret outside the sandbox, aslnk-telegramdoes. It isnullwhen the harness holds the secret.
The channel is the harness's own when its adapter speaks it. Each adapter
that does lists it in channels and writes it in configure, reading its
section of link_plugin::harness::Settings, which is under its name in
channels for a channel from outside Link. For a harness with an
endpoint, the plugin carries the messages too, with bridge,
main_with_bridge and ask.
A channel whose account is linked as a device also answers link and
state (main_linked), as lnk-whatsapp and lnk-signal do. Each
adapter that speaks it answers link, unless the channel's plugin
installs a program into the harness's home and links it there (cli install, cli link, and cli in describe). lnk-signal does that
with signal-cli for harnesses that speak Signal.
Then add the channel to PLUGINS and needs() in the core and to the
release workflow, as for any plugin. A channel from outside Link needs
only to be on the PATH.
Adding a cloud
A cloud is a plugin, src/local/plugins/<name>, that answers the adapter
commands.
They are connect, defaults, sizes, launch, wait, start, list,
describe, add-key, save-hostkeys, tag, stop, state, remove,
cleanup, hostkeys, forget, for images image-save, image-list and
image-remove, and for buckets storage, label, buckets,
bucket-key and revoke-key. The contract is link_plugin::cloud.
A cloud owns everything about itself, which is its CLI or API, its names
and its sign-in. The account it describes is the one sign-in every plugin
acts as, boxes through its CLI and buckets through storage. So
lnk-cloud, lnk-box and lnk-bucket never know which cloud they're on.
Pass the account's keys to the cloud's CLI in its environment, never on a
command line, after removing the shell's own settings for that cloud.
Then add a line to KNOWN in link_plugin::cloud, add it to PLUGINS
in the core with needs() giving cloud, and add it to release.yml's
PLUGINS, as for any plugin. lnk-aws and lnk-gcp are the examples,
and their tests/adapter.rs and tests/images.rs drive a fake CLI.
Decisions says why it works this way.
