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:

  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, 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.

  1. Choose its subject home, add the member to the workspace, and use link-plugin-<id> as its Cargo identity. Shared local dependency paths belong in workspace.dependencies.
  2. Declare its kind, menu, removal and requirements in plugin.toml, and its commands in actions.toml: each one's help, whether lnk hands it the commands under it (dispatch), and its arguments' help, their shapes written in from the typed parser.
  3. 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.
  4. 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.
  5. Keep adapter unit tests independent of operational engine installation.
  6. Write the component's pages in its own docs/en, including security behavior and decisions. Add them to scripts/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.

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 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_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' VERSION comments 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 .env with merge_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 --json with what its harness keeps in each agent's home (link_harness_contract::harness::homes): a credential its harness made, or a login, as the agents part's credentials, for lnk secret list.
  • 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_harness_contract::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.

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 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 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:

  • connect asks 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'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 connection, so it names no connection.
  • rule --json says how a harness speaking the connection 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 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.