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:

  1. 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.
  2. 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> --json instead (Plugin Contracts), or the other plugin hands P what it needs as data.
  3. 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 runs
  • about is the command's help and after is 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's cli names where its help is (link_plugin::help::Help), its main parses 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.toml at their folder's root, with an entry per command they answer, which their --help lists and a test checks against their main.

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.

  1. Create src/local/plugins/<name>, the crate link-plugin-<name>. It is a lib and an lnk-<name> binary whose clap parser is named lnk, with its group as the top subcommand (lnk-harness agent start), so its help reads lnk agent start. Each command's arguments are one type in the lib's cli module, listed in src/tests/e2e/tests/flags.rs (Specs and Command Lines). It may depend on link-plugin and link-agent, never on another plugin.
  2. Register it:
    • In the root Cargo.toml, under members, and under [workspace.dependencies] when another crate depends on it.
    • In src/local/cli/src/main.rs, add its group to GROUPS, its commands to COMMANDS and it to PLUGINS. Add a USES entry if lnk up should offer it, and needs() if it's no use alone.
    • In PLUGINS in .github/workflows/release.yml, from which the release builds link-plugin-<name> and packages it. src/tests/e2e/tests/release.rs checks the two lists match.
    • src/tests/e2e/tests/help.rs fails until COMMANDS says what its --help says.
  3. To make it exposable, give it a serve <target> --json command like lnk model serve in the models plugin, and have the tunnel run it for lnk tunnel open <target>.
  4. 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, so lnk uninstall and lnk plugin remove never name it.
  5. Test its logic through its lib, and end to end with a relay, an agent and a PortService pointed at what it serves, as src/tests/e2e/tests/llm.rs does.
  6. Write its page, docs/en/<part>/README.md and security.md, a line in docs/en/README.md, its settings in docs/en/CONFIGURATION.md, its crate in the install from a branch in Install lnk (release.rs checks 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 with HOME set 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' VERSION comments say how to move to a new release. It configures the harness with its own config set.
  • It puts secrets in the harness's own files with merge_env_file, never in Launch.env, which ends up in a service definition other local users can read.
  • It answers contract with link_plugin::harness::Contract::new(proxy), and proxy is true only when a test shows the harness reaches its model at model.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 models only if the engine calls a model on this machine.
  • Name an embedding model in model to 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:

  • connect asks 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's id is what another agent's connect refuses. lnk-telegram, lnk-slack and lnk-discord are the examples.
  • describe --json gives 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 --json says how a harness speaking the channel reaches the service with a placeholder while the proxy adds its secret outside the sandbox, as lnk-telegram does. It is null when 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.