Write a plugin for a harness, store or cloud
Add a plugin, a harness, a memory engine, a connection or a cloud to Link, with its code in the plugin it belongs to.
How a plugin runs
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, signal talks to
Signal, 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, auth, and
the others ask lnk auth relay.
An outside harness and Signal, worked through
Say a harness from outside Link speaks Signal itself. The code setting up its own Signal runs only for that harness, and only its adapter knows it, so it goes in that adapter, in its own repository. The Signal plugin carries messages for any harness that speaks no Signal, and never names a harness. So code for "that harness and Signal" goes in its adapter, and code for "Signal for any harness" in the Signal plugin. Code that knows Signal but sits in neither, such as the harness plugin or the sandbox, fails the test.
A command's help
Each plugin owns detailed help in its plugin.toml; standalone core
commands use their Cargo Link metadata. Markdown owns full explanations.
Help and lnk variable list read the same declarations, with environment
inputs listed separately from command arguments and flags.
# actions.toml
["agent start"]
help = """
Start your agent (Link harness by default), in the background
The first paragraph is the short help; the rest shows with --help.
"""
["agent start".args.model]
type = "string"
help = "The model: a local one by name, or one the provider names."
long = "model"Help checks compare every answered command and flag with its owning typed
parser. What the parser owns, each argument's type, spelling and
default and whether an action is hidden, is written into actions.toml
with LNK_WRITE_COMMAND_SCHEMAS=1 and the option-schema test, which
keeps the help and refuses a file the parser disagrees with.
A flag many commands take
A flag more than one plugin takes (--json, --yes, --relay, --request, --quiet, --actions) is declared once, with its help, in link-component's shared module. An action names it with shared = true and leaves the shape to the parser, which must type it as the declaration does: a boolean --json is always the shared one, and actions.toml refuses it written out in full. The action's own help is only what it adds after the shared help, such as what --json prints, and the command shows both.
An agent's commands
An action is on the CLI unless its surfaces says otherwise: cli, and
command, an agent's command spelled with a colon after its namespace
(/model:show). One only an agent offers has no parser to check it,
and its aliases are what other harnesses call it (/model), which
run it too.
Spec first
Whatever a flag sets, a spec can say, so a flag of an action that
reads a spec (--spec) either spells one of its keys, saying which
(key = "model.default"), or is the action's argument, what it acts on
or how it answers; a test refuses one named like a key it doesn't
spell. Additional derived settings belong to
the operation's resolver, rather than being a second set of CLI options.
Adding a plugin
A plugin is an owner-local Cargo member with one lnk-<id> binary and
an adjacent plugin.toml.
- Choose its subject home, add the member to the workspace, and use
link-plugin-<id>as its Cargo identity. Shared local dependency paths belong inworkspace.dependencies. - Declare its kind, menu, removal and requirements in
plugin.toml, and its commands inactions.toml: each one's help, whetherlnkhands it the commands under it (dispatch), and its arguments' help, their shapes written in from the typed parser. - Embed its descriptor and owned pages with
link-component-build. Generic registry, routing, releases and help consume that declaration; no core routing or release list is edited. - Put cross-owner wire types and clients in the owner's
contract/. Compile access goes through contracts; executing a program declares its command separately, including typed options and platform predicates. - Keep adapter unit tests independent of operational engine installation.
- Write the component's pages in its own
docs/en, including security behavior and decisions. Add them toscripts/documentation-index.json, then run the docs checks.
See Components for the closed schemas and Plugin Protocol for installed answers and removal behavior.
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, written, state, connections, link, needs, and endpoint for one that
speaks no connection. The contract is link_harness_contract::harness, an owner-local contract dependency.
The harnesses Link ships
Link ships Link Harness, and builds and ships the OpenClaw, Hermes and
DeepSeek Harness adapters too (lnk-openclaw, lnk-hermes,
lnk-deepseek), each at the core's version. Their descriptors declare
the harness kind, command protocol, requirements and installer records.
The core's registry derives from those declarations.
One that speaks ACP
A harness that speaks ACP needs no connection code. Its endpoint
names where, and Link carries the connections to it, as
lnk-link-harness shows; one that speaks it over stdio is served there
by the harness hub, link-harness-hub. The endpoint answers GET /_link/proof with
link_harness_contract::harness::endpoint_proof, and the contract says proof, so
Link's connections send its key only to a connection that proves it's the
harness.
Its rules
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_harness_contract::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 own
config set, never with a secret on its command line, since a command's arguments are any user's to see: a value that holds one (a tool's or a local model's address, which holds its proxy's) goes in the harness's.envwithmerge_env_file, named in its config by variable where the harness reads${VAR}, or into the harness's config file itself, mode 600. - It answers
uninstall --parts --jsonwith what its harness keeps in each agent's home (link_harness_contract::harness::homes): a credential its harness made, or a login, as theagentspart'scredentials, forlnk secret list. - 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_harness_contract::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.
Its tests
plugins/agents/agent/tests/up.rs drives a fake adapter script.
Every harness's tests also run the conformance kit
(plugins/agents/harnesses/conformance, link_harness_conformance::check), which runs
its adapter as lnk-agent would and checks its endpoint.
What the kit checks
It checks that its contract speaks this Link's version (2, whose needs are
specs), the key and proof, that the connection answering the proof has
TCP_NODELAY, 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").
Who runs it
The core's own fixture harness, lnk-mini in
tests/fixtures/mini-harness, passes the kit, and the core's
end-to-end tests run on it. Link Harness's package runs the kit, and
each adapter's runs it.
An outside adapter runs both the same way, pointing the kit at the
public checkout's core/.
Its sandbox
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 agent's grants. What it needs beyond Link's
defaults, such as [network] mode = "host" as a sandbox's spec, 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_memory_contract, and the output is
Serve.
command
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 shipped with Link, declare its kind and operational
requirements in plugin.toml. lnk-link-memory, in Link's
tools repository, is the example. Its tests/agent.rs drives it through
lnk agent memory start and searches it from the agent's sandbox.
Adding a connection
A connection is a plugin that lets people text an agent, through lnk agent connect <connection>. It lives in a folder of its own beside the core,
connections/<connection>/, a package in the public workspace, as
lnk-<connection>, built on link_connection_contract (link-plugin by path
from ../../core), and its main takes the connection's About. A core
build never compiles it (why).
It answers three commands:
connectasks for what the connection needs, refuses a bot another agent has (check_taken), pairs the owner with a one-time code (pairing_code), and prints the connection'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 connection, so it names no connection.rule --jsonsays how a harness speaking the connection 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 connection is the harness's own when its adapter speaks it. Each adapter
that does lists it in connections and writes it in configure, reading its
section of link_harness_contract::harness::Settings, which is under its name in
connections for a connection from outside Link. For a harness with an
endpoint, the plugin carries the messages too, with bridge,
main_with_bridge and ask.
A linked account
A connection whose account is linked as a device also answers link and
state (main_linked), as lnk-signal does. Each
adapter that speaks it answers link, unless the connection'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.
Its tests
Its own tests run in its folder: unit tests of what it reads and
sends, its audit lists (tests/secrets.toml, tests/clients.toml), and
its end-to-end test (tests/bridge.rs), which starts the core's lnk,
built in ../../core, on the conformance kit's agent on the mini
harness (link_harness_conformance::agent::programs_beside) against a
fake of its service. What the core does whichever connection carries the
messages is tested in the core, on the mini connection
(tests/fixtures/mini-connection).
In the workspace
Add the member to the public workspace and declare its connection kind,
commands and requirements in plugin.toml. The release registry consumes
that declaration. It inherits the public version; the workspace's CI and
Dependabot cover it. A connection from outside Link needs only to be on
the PATH.
Adding a cloud
A cloud is a plugin, an owner-local plugin member, that answers the adapter
commands.
They are connect, defaults, sizes, launch, wait, start, list,
describe, add-key, remove-keys, save-hostkeys, tag, stop, state, remove,
cleanup, hostkeys, tunnel, 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_cloud_contract.
What a cloud owns
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.
Its descriptor and tests
Declare its cloud kind and applicable cloud command requirements in
plugin.toml; release and runtime discovery use that descriptor. lnk-aws and lnk-gcp are the examples,
and their tests/adapter.rs and tests/images.rs drive a fake CLI.
A cloud that hosts agents
A cloud that hosts agents itself, with no box, answers agent-host,
agent-list, agent-in, agent-out, agent-take, agent-give,
agent-left and agent-abort too, and may answer agent-backup,
agent-restore and agent-chat. lnk agent move then moves an agent
to the account and back in the move's two phases, lnk bucket push
and pull reach its Home there, and lnk agent chat send talks to it there.
What the adapter owns
The adapter owns the transport, its
service's API signed in as the account, and lnk only pipes the move's
streams (link_agent_contract::moving) to and from it. What it answers is
untrusted: every name, ID, hash and time is checked before it's used or
shown. One that doesn't answer agent-host hosts none. The mini host,
tests/fixtures/mini-host, answers every one of them from a folder
for the core's tests, encrypting nothing, and is never shipped.
More about it
Decisions says why it works this way, and Plugin Contracts lists every plugin's interface.