Plugin Contracts

Every lnk command whose output another program reads, what it prints, and who reads it. Changing one is changing an interface: keep old fields, add new ones. How plugins use them: plugins.md.

Agents, measures and models

lnk agent

  • lnk agent list --json: {"agents": [...], "unreached": [{"id", "name", "error"}]}: every agent, each as status --json prints one: this machine's and each running box's (lnk box run <box> -- agent list --json --here); unreached, the boxes it couldn't ask (id their machine ID). An agent that moved to a box is listed as the box says, or, while the box doesn't answer, as the note here says (running null, location the box). --here asks only this machine, and lists no notes. Each agent here also has processes, {"pids", "group"}: the main processes it runs as now (its service's and its channels' service's, or its start in a terminal; [] when it's stopped) and, on Linux, its service's own control group (/sys/fs/cgroup/.../lnk-agent-<agent id>.service, else null); and measures, the file its harness appends its own measures to (LNK_MEASURES); both null for an agent that moved away. Read by lnk agent list on another machine (--here), lnk agent connect <channel> (a bot or app another agent runs: bots, or from an older lnk, the channel's own field), lnk bucket (--here: each agent's files folder, by its ID, and whether it runs), lnk measure (--here: what to measure).
  • lnk agent status --json: {"id", "name", "machine", "harness", "running", "files", "port", "bot", "slack", "discord", "whatsapp", "signal", "channels", "bots", "exit", "location", "processes", "measures", "wire", "permissions", "beyond_defaults"}, for the agent -a names (else the only one): permissions, what it may do on this machine while its harness runs it, the agent's own and what the harness needs (["network", "developer-tools", "lan"], unsandboxed among them when it runs with no sandbox), and beyond_defaults, of that, what Link's defaults don't give, as lnk agent deny or lnk agent <harness> deny takes it back (["lan", "tcp github.com:22", "tool gh"]; both null for an agent that moved away); its ID and name, this machine's {"id", "name"} (machine.toml; null if it can't be read or written), its base port (null: the adapter's own), its bot {"id", "username"} (never the token; null without Telegram), its Slack app's bot {"id", "name", "team"} (its member id, never a token; null without Slack), its Discord bot {"id", "name"} (its user id, never the token; null without Discord), the channels connected (["discord", "slack", "telegram"], whether or not the harness speaks them), each one's bot in bots ({"<channel>": {"id", "name", "team", "title"}}, never a token; bot, slack, discord, whatsapp and signal are the same for Link's own channels, as they were before bots: WhatsApp's and Signal's {"id"} the agent's number), its exit ("home" or null), the active harness, whether the agent runs (in the background or in a terminal), its files folder, or null when that setting is one lnk agent start wouldn't accept as it is, location null, and wire, why its harness's wire is closed (every wire stopped, or its own paused, and why), else null; only status gives it, not list. For an agent that moved away, what its note says: its ID (null until the box says it), name, the machine it went to, location that machine's name, and null for the rest. Exits nonzero only when the settings can't be read. Read by lnk agent move (whether the agent answers where it went).

lnk measure

  • lnk measure [<unit>] --json: {"at", "units": [{"id", "kind", "parent", "name", "measures": {"<measure>": {"now", "day"}}}]}: this machine, each agent here and each of its processes (kind machine, agent, sandbox or process; a process's id <agent id>/<pid>), or the unit named and its processes; at in Unix seconds. Each measure is one of link_plugin::measure::MEASURES (measure.rs), its value now (null for what's counted as it happens: a harness's per turn, and tokens and model requests) and over the last day, as the watcher kept it (null without); one with neither is left out. For one unit, "hours": [{"at", "measures": {"<measure>": <value>}}], the last day an hour at a time, those with anything kept. Read by lnk agent list (each agent's link.memory.used.sum now and link.cost.sum over the day, by its ID) and Link Harness's density script.
  • lnk measure start --quiet: The watcher runs in the background once it exits; nothing said, and nothing done where background services are off. Read by lnk agent start.

lnk model

  • lnk model list --json: [{"name", "base_url", "models": ["..."]}], one entry per runtime found, at a default port or an address lnk model add remembers (name then the one it was given); with --start, after starting an installed Ollama that isn't running. Read by lnk agent start (--start: offers the models here, Ollama's too when it was stopped).
  • lnk model serve <target> --json: One line, {"url", "description"}, once it listens on a 127.0.0.1 port; it stops when its stdin closes. Exit status 3: no runtime here serves the model named. Read by lnk tunnel open <target> (exposes that port; on status 3 offers lnk model install), by lnk agent memory serve for a memory engine's model (passes <url> to the engine as $LNK_MEMORY_MODEL, empty on status 3 or when no line comes within 10 s), and by Link Memory with --embed <model> run outside a sandbox (calls <url>/v1/embeddings while it serves; on status 3 searches by words and says lnk model install <model>).
  • lnk model recommend --json: {"model", "size", "why"}, or null where Link recommends no local model. Read by lnk agent start (offers to install it when no model runs here).
  • lnk model install <model> --yes: Exit status 0 once the model is pulled and Ollama lists it. Read by lnk agent start (installs the local model picked).

Storage and accounts

lnk bucket

  • lnk bucket clouds --json: [{"name", "kind", "backend", "region", "buckets": {"personal" | "<agent id>": {"bucket", "encrypted", "remote", "files", "bytes", "agent"?: {"id", "name"}}}}], one entry per connected cloud; a folder without a bucket there is left out. Read by scripts only.
  • lnk bucket tree [<path>] --json: {"personal": [...], "<agent id>": [...]}, one key per folder (each agent's by its ID) (only the folder holding <path>, when given); each row {"path", "size", "here", "clouds": [["<cloud>", "<mark>"], ...]}, here one of here, changed, new, offloaded, a mark one of checked, archived, unchecked, differs, missing. Read by scripts only.

lnk auth

The accounts plugin's: your login to the relay. No other plugin reads its file.

  • lnk auth relay [--relay <url>] --json: {"relay", "token"}: the relay to use (--relay, else the one logged in to, else https://local.link) and the login token for it (null when not logged in to that relay). It refuses to print to a terminal. Read by lnk tunnel open and status, which connect with it.
  • lnk auth token --json: {"relay", "user", "token", "expires_at"}: a token for the relay's share API that expires in 10 minutes (or when the relay restarts), which the relay's GET /_link/whoami also takes, saying whose it is; fails when not logged in. Read by lnk bucket share, unshare, shares.
  • lnk auth ticket --json: {"relay", "ticket", "expires_in"}: a confirmation given ahead by this machine, for one new login of its account on its relay, lasting expires_in seconds (10 minutes); fails when not logged in, or the relay is older than tickets. Read by lnk box start, which hands it to the box's lnk auth login as LNK_LOGIN_TICKET, so that login, approved in the browser, needs no lnk auth approve.
  • lnk auth whoami --json: {"username", "relay", "user_id"}: who this machine is logged in as, and to which relay (username is null when logged out), and the GitHub account's id (null too for a login from before relays sent it). Read by lnk box start (installs lnk on the box from that relay and logs it in there), lnk box (tags boxes lnk-owner=github-<user_id>, and lists them by it), lnk bucket (labels an agent's buckets lnk-owner, and finds them by it).

lnk cloud

  • lnk cloud list --json: [{"name", "kind", "place", "identity", "login"}], one entry per connected account; never its keys. Read by lnk agent move (whether where it goes is a cloud account).
  • lnk cloud env <name> --json: One account as link_plugin::cloud::Account: {"name", "kind", "place", "identity", "login", "region"?, "profile"?, "project"?, "env"}, env holding what the cloud's CLI needs to act as it, keys included. Exits nonzero when there's no such account. Read by lnk box and lnk bucket (every adapter call, which each then makes itself: link_plugin::cloud::call), lnk bucket create --cloud and connect --cloud.

Boxes and the VPN

lnk box

  • lnk box forward <box> --socket <path> --to <local>: ssh -R to a Unix socket at <path> in the box's home folder, until it drops: a SOCKS proxy there, the one on this machine's loopback at <local>. The socket's folder is made first, only the box user's, and a socket left there by a connection before removed. --port <p> instead: on 127.0.0.1:<p> of the box. Without --to it refuses: ssh's own SOCKS, which reaches anything here, is never used. Read by lnk vpn exit <box> home (as its background service, under lnk sandbox socks).
  • lnk sandbox socks -- <command>: while <command> runs, a SOCKS5 server (CONNECT to an address, no authentication) on this machine's loopback at a free port, {port} in the command, that reaches only the internet, never this machine or its networks. Exits with the command's code. Read by lnk vpn exit <machine> home (as its background service).
  • lnk box run <box> -- <args>: lnk <args> on the box, its input and output passed through untouched, its exit status. Read by lnk agent move and lnk agent new --on (readying the box first: plugin add harness, sandbox ready --yes, agent route direct --default), lnk agent status (when the agent is on a box), lnk agent list (a box without the harness plugin has no agents).
  • lnk box start <account> --yes: Makes a box in the cloud account, without asking, talking on stdout; exit status 0 once it's up. It is then the newest (created) in account of lnk box list --json --here. With --image <name>, the box's disk starts from that image of yours there. Read by lnk agent move (to a cloud account with no box of yours, and with --image always).
  • lnk box list --json: [{"name", "account", "kind", "place", "id", "machine_id", "size", "ip"?, "state", "created", "found", "stopped_by_cloud"}], one entry per box: those this computer knows and those each connected account has tagged as yours (created: when this computer made it, in Unix seconds, 0 for one found in a cloud; found: "here", or "cloud" for one found just now; id is the cloud's instance id, machine_id its machine ID, empty for a box from before IDs until Link reaches it; stopped_by_cloud: a spot box its cloud stopped, as its adapter says; from an adapter that can't say, a spot box this computer last saw running and found stopped, as another computer's lnk box stop leaves it too). --here asks no cloud; --refresh asks each for each box's state. Read by lnk agent move (which boxes there are, and in which account: a cloud account's name moves to its one box, unasked only when its created isn't 0, made on this computer), lnk agent list (which to ask), lnk vpn exit (--here: whether a machine is a box).

lnk vpn

  • lnk vpn exit <machine> home|off [--for <id>] [--port]: Holds (home) or gives back (off) the machine's exit for <id> (default you): a SOCKS proxy whose connections leave from here, running while anyone holds it, on a Unix socket at ~/.config/lnk/vpn/exit.sock of the machine. With --port, <id> takes it on the machine's 127.0.0.1:40780 instead, and it's there while any holder asked so. Exit status 0 once done. Read by lnk agent exit, lnk agent move (for the agent's box, by the agent's ID; --port for a box whose lnk takes no socket).
  • lnk vpn list --json: [{"machine", "via", "running", "holders", "socket"}]: the exits this computer keeps (socket: on the socket, not the port). Read by lnk agent status (whether its box's exit is up).

Moving an agent

What moving an agent between machines uses (lnk agent move, restore); most are hidden from --help.

  • lnk agent send: The stopped agent as a tar stream on stdout: lnk-move-2.json ({"format": 2, "version", "agent": {"id", "name"}, "harness", "model", "connected", "telegram"?, "bot"?, "slack"?, "slack_bot"?, "discord"?, "discord_bot"?, "whatsapp"?, "whatsapp_number"?, "signal"?, "sandbox"?, "spec"?, "exit", "state", "channels", "bucket"}, sandbox what you granted the agent (its sandbox file, when it has one), spec its own spec's text, connected the agent's channels as its settings keep them ({"<channel>": {"settings", "bot", "about"}}), and the per-channel fields the same for Link's own channels, for an older receive; a manifest without connected is read from them, bucket what lnk bucket export says, or null), then harness/... (the state lnk-<name> state names) and files/... (the files folder), links as links. The manifest's name is its format's, so an older receive finds none. Read by lnk agent move.
  • lnk agent receive [--no-start]: Reads one JSON line ({"key"?, "upstream"?, "bucket_keys"?, "sandbox"?, "grants"?, "yes"?}: a model key, where its traffic goes there, for a box, lnk bucket keys' keys, and, when the agent leaves the machine running the move, what it may do there: sandbox the agent's sandbox file as that machine has it, kept whole, and grants its harness's file (~/.config/lnk/harnesses/<harness>.toml), added to the harness's here; without sandbox, the agent keeps its file here, else takes the stream's without read-home, localhost, lan, open, tcp or tools; yes, what its harness asks for beyond the defaults is allowed) then send's stream on stdin; puts the agent in place here, as the agent the stream names, its buckets with it (lnk bucket adopt), and starts it; move always passes --no-start, then asks lnk agent needs and starts it with lnk agent start. Refuses another format (lnk-move.json is the one before), an agent here of that name with another ID, and one of another name with its ID. Read by lnk agent move.
  • lnk agent needs (hidden): {"harness", "needs": [[name, why]], "running": [agent]}: for the agent -a names, what its harness needs beyond Link's defaults (lnk-<harness> needs) and hasn't been answered on this machine, and the agents here running that harness. --allow <need> (repeated): grants those to the harness first, each one it asks for now, as answered; one it doesn't ask for refuses them all. Read by lnk agent move (where the agent came, before it starts there: asked in the move's terminal, granted with --allow).
  • lnk agent left --to <machine> [--to-id <id>]: On the machine an agent left, once it answers on <machine>: deletes its settings, harnesses and service, keeps its files 30 days (or machine.toml's keep_moved), writes its note. Read by lnk agent move.
  • lnk bucket export <id> (hidden): {"password", "buckets": {"<cloud>": "<bucket>"}}: agent <id>'s buckets here and their password, obscured as rclone keeps it; null without any. Read by lnk agent send.
  • lnk bucket adopt <id> (hidden): Reads {"password" | "plain", "buckets", "keys"?: {"<cloud>": {"bucket", "key"}}}; puts the agent's buckets in place here: its encryption on buckets this machine knew, buckets in clouds of the same name, and a cloud <cloud>-<id> for it alone from each key. Nonzero when none is reachable (a wrong password). Read by lnk agent receive, and lnk agent restore from before bucket restore.
  • lnk bucket forget <id> (hidden): The agent left: its encryption and clouds only for it go; its buckets stay known. Read by lnk agent left.
  • lnk bucket keys <id> / revoke <id> (hidden): {"<cloud>": {"bucket", "key"}}: through lnk cloud, a key reaching only each of the agent's buckets (found by its lnk-agent label), any before replaced; or those keys gone. Read by lnk agent move (to a box; back home).
  • lnk bucket restore --name <name> [--id <id>] --into <folder> (hidden): Finds agent <name>'s buckets in your lnk cloud accounts by their labels (and yours, lnk-owner), asks their encryption password (on stderr, reading stdin), puts them in place here as adopt does, with a key reaching only them where a cloud makes one, and pulls the agent's files into <folder>; prints {"id", "buckets": {"<cloud>": "<bucket>"}} on stdout, and all else on stderr. Refuses a name agents of two IDs have, without --id. Read by lnk agent restore, the one bucket command it runs.
  • lnk bucket find [--name] [--id] (hidden): [{"cloud", "bucket", "place", "id", "name"}]: agents' buckets in your lnk cloud accounts, by their labels (and yours, lnk-owner). Read by lnk agent restore from before bucket restore.
  • lnk --version: lnk <version> on its first line, then where it runs from and any other lnk on the PATH. Read by lnk agent move (through lnk box run: a box older than this computer is upgraded with lnk upgrade <version>).
  • lnk agent route <upstream|none> [--default] (hidden): On the machine the agent runs on: its proxy's upstream (direct, socks5://127.0.0.1:<p>, socks5://<socket>, or home: the home exit's socket in this machine's home folder, which an lnk from before it refuses), or none for no route of its own (direct, unless the harness has lan); restarts it if running. With --default, this machine's default (agent-defaults.toml): every agent made here from then on starts with it, and one here with no route yet takes it. Read by lnk agent exit (through lnk box run; home, else the port for a box that refuses it), lnk agent move and lnk agent new --on readying a box (route direct --default).

lnk sandbox

A plugin confining a program builds a link_plugin::sandbox::Policy and calls link_plugin::sandbox::wrap (lnk sandbox wrap, installing the sandbox plugin first if it's missing), then runs the command it gets with the environment it chooses. The sandbox decides only what the program may touch; what runs, where and with which environment stays the caller's. The contract's source is sandbox.rs. A sandbox's spec, link_plugin::sandbox::Spec (sandbox/spec.rs), is the type a plugin keeps one as; what it opens is the sandbox plugin's to say, by running it.

  • lnk sandbox wrap: Reads {"policy", "argv"} (link_plugin::sandbox::WrapRequest: the policy {"name", "write", "read", "write_dot_files"?, "hide"?, "tmp_prefix"?, "ports", "permissions", "proxy"?: {"upstream", "dir", "serve", "hosts"?, "tcp"?, "tools"?, "models"?, "model"?: {"provider", "key", "server_tools"?}, "listen"?, "per_minute"?, "secrets"?: [{"name", "value", "host", "paths": [{"prefix", "rest"}], "header"?: {"name", "value"}, "refuse"?, "methods"?, "public"?}], "secret"?, "lan"?, "no_internet"?, "browser"?}, "loopback"?, "terminal"?, "commands"?}) on stdin; prints {"argv", "env"}, the command running argv confined to it on this OS and what to add to its environment (with a proxy, HTTPS_PROXY and the rest, each of tools' address, LNK_TOOL_<NAME>, and each of models' on the proxy, LNK_LOCAL_MODEL_<PORT>, and with open, BROWSER and the verb's address, LNK_SANDBOX_OPEN; all three carry secret, 16 to 64 letters and digits the proxy requires on its tool, local model and open verbs, or one of the wrap's own), after writing its rules to ~/.config/lnk/sandbox/<name>.sb (macOS) a hosted model's key to <name>.model.json each of secrets to <name>.secret-<secret>.json and secret to <name>.url-secret beside them (the program reaches the model at link_plugin::sandbox::model_url with placeholder_key, a local model at local_model_url, and a secret's service at secret_url with placeholder_secret: link_plugin::sandbox::Secret says where the proxy may send the secret and what it refuses; on macOS the proxy listens on listen, else a free port). With loopback (a folder), it reaches the policy's ports on this machine's loopback and nothing else, without a proxy (on Linux, relayed through sockets in that folder); refused beside proxy, network, localhost, lan or open. Link's settings stay closed whatever the policy says; hide closes folders the same way, but for what write and read name in them. At the top of each of write, a project's dot files that run outside (.git, .envrc, .vscode, .idea) are read only, but in those write_dot_files names. Read by lnk-harness (every step of a harness: install, configure, command, health, the harness).
  • lnk sandbox traffic --json: [{"pid", "sandbox", "up", "down", "model"?}]: what each running sandbox's proxy (its process, pid) has carried since it started, every call it let through, in bytes from the sandbox (up) and to it (down); for a proxy that carries a model (a hosted one's key, a local runtime's port), model, link_plugin::measure::Tokens, {"requests", "unmetered", "input", "output", "cached", "written"}: the requests for a model's answer it carried that were answered, of them those whose answer gave no usage, and the tokens the rest's usage said (the prompt's, cached or not, Anthropic's three parts summed; the answer's; of the prompt, read from the provider's cache, and written to it). Read by lnk measure (an agent's network and tokens: its proxies' among its processes).
  • lnk sandbox paused --json: link_plugin::sandbox::Wires, {"stopped", "paused": [{"sandbox", "why"}]}: whether every wire is stopped, and the sandboxes (<agent>-<harness>, guest-<name>, run-<pid>) whose wire is paused, and why. Read by lnk agent status (link_plugin::sandbox::wires).
  • lnk sandbox on-stop <name> [-- <args>] (hidden): lnk <args> runs when lnk sandbox stop closes the wires, for <name>, kept in ~/.config/lnk/sandbox/on-stop/<name>.json; with no args, nothing does anymore. Never lnk sandbox itself. Read by lnk agent start (agent-<agent>: agent stop -a <agent>, so the kill switch stops an agent with lan too, which has no wire), and forgotten when the agent leaves.
  • lnk sandbox ready [--yes]: Exit status 0 once the sandbox can run here; in a terminal, offers what it needs (Ubuntu's AppArmor profile), talking on stdout. With --yes, asks nothing and adds it where sudo -n works (installing bubblewrap with apt if it's missing). Read by lnk agent start, lnk agent <harness> check, and lnk agent move and lnk agent new --on readying a box (--yes, through lnk box run).
  • lnk sandbox tool list --json: One JSON object per tool that runs outside: {"name", "argv", "ask", "allow"} (ask: all or none; allow: the calls that don't ask). A proxied policy's tools names some of them; its program finds each at $LNK_TOOL_<NAME>. Read by lnk agent allow tool, and lnk agent start (a tool removed since is left out).
  • lnk sandbox check --policy: Reads a Policy on stdin (a proxied one too), runs the probes inside it, prints a line per probe (blocked, allowed, failed, open, LEAK); exit status 1 when something leaked. Read by lnk agent <harness> check.

Adding and removing plugins

lnk plugin

The core's.

  • lnk plugin list --json: A JSON list of link_plugin::plugins::Listed, {"name", "installed", "source"?, "key"?}: Link's plugins, then each added from outside Link with the repository it came from (https://github.com/<owner>/<repo>) and the key pinned for it (ssh-ed25519 <base64>). Read by lnk-harness (link_plugin::plugins::outside: lnk agent use lists the outside harnesses, a restore takes their backups, and a move adds the agent's on a box).
  • lnk plugin add <url> --yes --key <key>: Adds the plugin from <url>'s latest release, trusting only <key>, without asking. Run by lnk agent move and lnk agent new --on on a box (lnk box run), with the source and key this computer pinned.

Uninstalling (lnk-<plugin> uninstall)

  • lnk-<plugin> uninstall --parts --json: link_plugin::uninstall::Kept: {"parts": [{"name", "about", "paths", "keys", "first"?}], "stops"?, "stays"?}: what it keeps, by part (agents, personal, buckets, clouds, relay, or its own), each part's folders and files (absolute, inside ~/.config/lnk or ~/Link, there or not) and Keychain accounts (exact, or a prefix ending in /), how to keep a copy first; what uninstalling stops, and what stays the user's elsewhere. A plugin that doesn't answer keeps nothing the core knows of. Read by lnk uninstall (each installed plugin, parts of one name merged; rest is what none claims) and lnk plugin remove (to say what stays).
  • lnk-<plugin> uninstall [--delete <part>]...: Stops its background services, then deletes the parts named (link_plugin::uninstall::run); exit status 0 once done. Read by lnk uninstall (each installed plugin, before its program goes, with the parts picked) and lnk plugin remove (no part: only its services stop; on a failure it keeps the program and shows the plugin's stderr).

Adapters, engines and channels

Harness adapters (lnk-<name>)

What lnk-link-harness answers, and every harness adapter from outside Link.

Versions. The core and an outside adapter release apart, so the contract has a version (link_plugin::harness::VERSION, now 1), and each side keeps working with the other's older ones. A change is additive, as in the relay's protocol: a new command an older adapter doesn't answer, with what lnk-harness assumes without it (each command below says), or a new field with a default. Only a change an older side can't follow bumps the version.

install, configure, command and health run with HOME set to the harness's home, TMPDIR its tmp/, LNK_FILES the files folder (and LINK_FILES, its old name, for older adapters), LNK_AGENT the agent's name, LNK_AGENT_ID its ID and LNK_OWNER its owner (github-<user id>, or <user>@<machine> before you sign in), LNK_PORT the agent's base port (not for main: the adapter's default; its ports go from there, within as many as its contract's ports and one more, or 200 when it doesn't say, and Launch.ports outside that are refused), LNK_MEASURES a file in its home where the harness may append its own measures, a line of OTLP JSON at a time (link_plugin::measure::append: what it counts itself, a turn's tokens, cost, calls and times; the rest is dropped as lnk measure reads it), inside the sandbox (unless it's off); traces, channels, state, hosts, needs, endpoint and contract run outside it, and only print. The contract's source is harness.rs.

  • lnk-<name> contract: link_plugin::harness::Contract as JSON: {"version", "proxy", "proof"?, "agent"?, "ports"?, "instructions"?, "from"?, "recover"?, "tools"?, "folders"?, "prompt"?}, the version of this contract it speaks (1: contract and Launch.wait_secs; needs, proof and agent came after, without a new version), whether its harness reaches a hosted model at model.base_url (Link's proxy, which then holds the key: keys on the wire by default), whether its endpoint proves it's the harness before it's sent the key (proof, below; left out, no), whether its endpoint reads which agent a request is for (agent, below; left out, no), and how many ports from LNK_PORT its harness takes (ports; Link gives the agent that many and one more, for its proxy on a Mac; left out, 200), whether it takes the agent spec's [instructions] (instructions; left out, no: a spec setting them is refused for it), whether its endpoint reads who a message is from (from, below; left out, no: it's never sent a message from anyone but the owner, and gets no scheduler), whether its endpoint hands over the answers of turns it finished after a crash (recover, below; left out, no: they stay in the conversation), whether its endpoint keeps a conversation's tools (tools, below; left out, no: a subagent has all its parent's tools, and the bridge refuses to send a list), whether it keeps to the files folder's layout (folders: it writes only conversations/ there itself, so its sandbox reads the files folder and writes only conversations/ and the folders the agent's sandbox file's [files] write names; left out, no: its sandbox writes the whole folder, and a start says [files] holds only for Link's tools), and whether it answers instructions (prompt, below; left out, no: lnk agent instructions says it can't). An adapter without it speaks version 0 and answers no, but for the three harnesses Link's releases once carried (lnk-openclaw, lnk-hermes, lnk-deepseek), which keep their keys on the wire as they did; a version 0 adapter also gets 180 s to answer after a start, and lnk agent start says it gets no updates. Read by lnk-harness (the default of lnk agent keys) and lnk plugin add <url>, which installs only an adapter answering a version of at least 1.
  • lnk-<name> install: Installs the harness with its own installer, or does nothing; asks nothing. Says so first when it installs, and shows progress. Read by lnk-harness.
  • lnk-<name> configure: Reads link_plugin::harness::Settings as JSON on stdin ({"files", "model": {"provider", "api_key", "name"?, "base_url"?}, "<channel>"?: {...}, "endpoint_key"?, "memory"?, "tools"?, "allowed"?, "unsandboxed"?, "proxied"?, "spec"?, "defaults"?, "model_facts"?, "environment"?}) and writes the harness's own config, leaving out a channel it doesn't speak. Each connected channel's section is under its name, as its plugin's connect made it; Link's own: "telegram": {"bot_token", "allowed_users", "api_root"?}, "slack": {"bot_token", "app_token", "allowed_users", "dm_channel"?}, "discord": {"bot_token", "allowed_users", "dm_channel"?}, "whatsapp": {"allowed_users", "self_chat"} and "signal": {"account", "allowed_users", "program"?} (typed in link_plugin::harness; program the signal-cli lnk-signal cli install put in the home); a channel from outside Link is in Settings::channels, and no channel is named files, model, endpoint_key, memory, tools, allowed, unsandboxed, proxied, spec, defaults, model_facts or environment. endpoint_key, for an adapter with an endpoint: what its endpoint takes (Authorization: Bearer <key>), refusing anything else; a channel Link carries for it is left out of the settings. telegram.api_root, with keys on the wire: where it must reach Telegram's Bot API, its proxy, which adds the real token to bot_token's placeholder (lnk-telegram rule --json). memory: the address of the agent's memory, an MCP server over HTTP through the harness's proxy (lnk agent memory), there only while the agent has one and its harness runs behind its proxy. tools: each tool that runs outside it may call (lnk agent allow tool), by name, at its address on its proxy (an MCP server over streamable HTTP). The adapter adds each of tools, and memory as memory, to its harness's MCP servers (Settings::mcp_servers), and removes those it added before that are gone (link_plugin::harness::tools_gone). allowed: the risks its needs named that the user allowed, by name; the adapter leaves out what any other would open, or fails, saying how to allow it, when the harness can't run without it. unsandboxed: true when it runs with no sandbox at all, its ports on the machine's loopback. proxied: true when it runs behind its proxy (sandboxed, with network but not lan, or routed through an exit): on Linux its ports other than those its proxy serves (Launch.serve) then stay in the sandbox's own network; left out, they're on the machine's loopback, as they always are on macOS, and an adapter names a risk such a port opens. Link before it never sends it, so its absence is no proxy; an adapter from before it reads it as a channel it doesn't speak. spec: the agent's opinions, its spec as it runs it (Link's, its teams' and its own), which a harness takes in place of any of its own; model.name is already its spec's default, and one its allow lists. defaults: the keys of spec that are Link's, set by no team and not by the user (["window.max", ...]): a harness holds a default of Link's to what the model takes, and takes a key the user set as set. model_facts: what Link and the user know of model.name (link_plugin::models::Known, {"name", "window"?, "answer"?, "thinking"?, "price"?: {"input", "output", "cached"?}, "yours"?}: its whole window and longest answer in tokens, how it's asked to think, adaptive, budget, effort or no, its price in dollars a million tokens, and which of these the user's ~/.config/lnk/model-facts.toml set), left out for a model neither knows (lnk model facts). environment: what the agent runs with here, as Link resolved it (link_plugin::harness::Environment, {"channels"?, "folders": {"write", "read"}}): the channels it's reached on, by their plugins' names, and its sandbox file's [files], the folders of the files folder it may write and those it may only read (link_plugin::harness::layout: instructions/, skills/, conversations/, work/), for a harness to tell its model. Prints nothing unless it fails. Read by lnk-harness.
  • lnk-<name> instructions [--json]: For an adapter whose contract says prompt: prints the system prompt its harness sends its model now, exactly, from what configure last kept and the files folder as it is; with --json, {"system", "sections": [{"name", "owner", "changes", "from", "bytes", "max", "cut"?}]}, each section of it in order. Runs in its home, the files folder read, nothing else of the agent's, with no network. Read by lnk agent instructions.
  • lnk-<name> channels: A JSON list of the channels its harness speaks, by their plugins' names (["telegram", "slack", "discord", "whatsapp", "signal"]); an adapter without it speaks Telegram. Read by lnk agent connect (refuses one the harness lacks), lnk agent use, lnk agent status.
  • lnk-<name> command: Launch as JSON: {"argv", "env", "write", "read", "ports", "serve"?, "services", "wait_secs"?}, how to run it in the foreground; lnk-harness refuses one that would widen the sandbox. serve: of ports, those Link reaches from outside the sandbox (its gateway or endpoint, its health check), the only ones served on this machine's loopback behind its proxy; left out, all of ports. wait_secs: how long it may take to answer health after it starts, at most 600; left out, 60 (180 for a version 0 adapter). Read by lnk-harness.
  • lnk-<name> health: Exits 0 when the running harness answers, reaching it on its ports on this machine's loopback, the only network it has in the sandbox. Read by lnk-harness (lnk agent status).
  • lnk-<name> hosts: A JSON list of the hosts its install and configure reach, as lnk agent allow host takes them (["registry.npmjs.org", "*.githubusercontent.com"]). With the sandbox and network, every step runs behind a proxy of its own, direct, and those two reach only these hosts; an adapter without the command lets them reach any host on the internet. link reaches what the agent may while it runs (lnk agent allow host, tcp; none listed: any host), and health only the harness's ports on this machine's loopback. Read by lnk-harness.
  • lnk-<name> needs: Reads link_plugin::harness::Settings on stdin as configure does, but with no key or token (the model's api_key and each channel's secrets empty), and prints link_plugin::harness::Needs as JSON: {"permissions"?: [{"name", "why"}], "unsandboxed"?: {"why"}, "risks"?: [{"name", "why", "without"?}]}, what its harness needs beyond Link's defaults (network, developer-tools) with those settings (unsandboxed among them: true when the user runs it with no sandbox; and proxied, with the permissions it has so far, so a harness that asks for lan itself counts it), each with why in its own words (one line, at most 200 characters). permissions names permissions (lan, localhost, read-home, open, ...); a name Link doesn't know, or one named twice, is refused, and the harness doesn't start. unsandboxed is only for a harness that can't run in any sandbox. risks names what running it risks that no permission does (a port of its own that asks for no credential), each by a name of its own: lowercase letters, digits and dashes, at most 32, and none lnk agent allow or deny takes for anything else; one with without (one line: what it does without it) runs either way, and only a yes in a terminal allows it; one without without is asked as a permission is. configure gets those allowed in allowed. lnk agent use, a start and lnk agent move ask the user for each not answered yet, and grant what they allow to the harness (~/.config/lnk/harnesses/<name>.toml), held only while it runs, remembering the answer; a permission the agent has of its own (lnk agent allow) is had, and not asked. An adapter without the command, whatever version it speaks, needs nothing more: one that exits with an error (its answer to a command it doesn't know) or prints nothing. An answer that isn't Needs is refused. Read by lnk-harness.
  • lnk-<name> endpoint: For a harness that speaks no channel itself: a JSON string, the address of its chat completions endpoint on this machine's loopback ("http://127.0.0.1:18800/v1/chat/completions"), at one of its Launch.ports, LNK_PORT taken into account. Link then carries the channels whose plugins carry messages (describe's bridged: all five of Link's) for it: each message from the owner is POSTed there alone, {"model", "messages": [{"role": "user", "content"}], "stream": true}, never the thread's history, with Authorization: Bearer <endpoint_key>, Link-Thread: <channel>:<chat id> (telegram:123456789, slack:D0123ABCDEF, discord:<channel id>, whatsapp:15551234567, signal:15551234567) and Link-Agent: <agent> (main), with Link-Agent-Id: <its ID> when it has one; the answer is read as chat completions, streamed (text/event-stream) or whole. model is the agent's model's name. With the contract's proof, each message goes on a connection of its own that first proves it's the harness: GET /_link/proof at the endpoint's host and port, with Link-Challenge: <32 to 128 hex characters>, answered 200 with Link-Proof: <HMAC-SHA256 of "link endpoint proof", a zero byte and the challenge, keyed with endpoint_key, in hex> (link_plugin::harness::endpoint_proof); a connection that doesn't is sent neither the key nor the message. An endpoint that answers the proof sets TCP_NODELAY on the connections it accepts: each message is a new connection, and without it a short answer waits about 40 ms for the channel's delayed ACK. An answer, streamed or whole, and a line of a stream, are read up to 16 MiB; past that the owner is told the agent failed. Without proof, the key and the message go to whatever answers on the port. The proof's request carries Link-Agent too. With the contract's agent, the endpoint answers a request as the agent Link-Agent names, with that agent's key, and one naming none as its own agent's (LNK_AGENT); one naming an agent it doesn't run is refused (404) before the model sees it, so one process could answer for many agents. Names are unique only to an owner: an endpoint answering for more than one owner's agents finds the agent by Link-Agent-Id, refuses one whose ID and name disagree (404), and one naming by name alone an agent two owners share (409). Without agent, every request is its own agent's. With the contract's from, a message from someone who isn't the owner carries Link-From: <sender> (schedule:<id>, task:<id> or agent:<id>, link_plugin::channel::FROM_HEADER, is_from) and Link-Hops: <n>, how many messages, each sent by an agent on its own, led to it; the endpoint records who it's from, tells the model who's speaking (never as the owner), refuses a Link-From naming no such sender (400), and passes the hops on to its tools. Its harness's environment then holds $LNK_SCHEDULER (link_plugin::memory::SCHEDULER_ENV), the agent's scheduler, an MCP server over HTTP through its proxy, beside $LNK_MEMORY; a call to it carries _meta {"link.local/thread", "link.local/hops"}, the conversation it's made in and the hops of the message being answered. With the contract's tools, a message may carry Link-Tools: <names> (link_plugin::channel::TOOLS_HEADER, tools_header, tools_of): the tools its conversation may use from then on, by the names the harness offers them (memory__search), comma-separated, at most 256, each 1 to 128 letters, digits, _, - and ., or empty for none. The endpoint keeps the list with the conversation and offers it only those of its tools from then on; a later list narrows it to the tools in both, never widens it; a message without one keeps it; one that isn't a list is refused (400) before the model sees it. A call to its tools then carries _meta "link.local/tools" too, the names of the tools the conversation has this turn, which the scheduler reads so a subagent gets its parent's tools or fewer. Link sends it only with a message from others whose Firing has tools (a subagent's task). with 202 and Link-Steered: 1 and no body (link_plugin::channel::STEERED_HEADER, channel::ask_for's Asked::Steered): the answer on the request that started that turn answers it too, and the bridge marks the message as its spec's steered says, or not at all. With the contract's recover, POST /_link/recover at the endpoint's host and port (link_plugin::channel::RECOVER_PATH), with the same Link-Agent, Link-Agent-Id and key as a message, answers {"answers": [{"thread", "text"}]}: each answer of a turn it finished after a crash, which nobody waited for, handed over once (channel::recovered); a request without the key is refused (401). Its body empty or {}, that is all. With {"two_phase": true}, each answer also has an "id" (an opaque string) and is kept, handed over again at each ask, until a POST with {"done": ["<id>", ...]}, sent once it went to the chat, lets those go; that one answers {"answers": []}, and an id that isn't one is passed over. The harness finishes the crashed turns in a task of its own, so a bridge that stops waiting leaves none half done. The agent's bridge asks as it starts and every [turns] lapse after, and sends each to the chat its thread is. An adapter without the command speaks its own channels. Read by lnk-harness (lnk agent start, connect, use, status).
  • lnk-<name> state: link_plugin::harness::State as JSON: {"include", "exclude"}, paths ~/... in its home that hold the agent, and those under them that are its install (programs, runtimes, logs, caches, .env). Read by lnk agent send.
  • lnk-<name> link <channel> [--again]: For a linked channel (describe's linked: WhatsApp, Signal), whose account the harness links itself: links it in the home, its QR code on the terminal, once configured. Exits 0 at once when it's linked (--again: links it anew), 3 when it isn't and there's no terminal. Read by lnk agent connect (--again), lnk agent start and use (before starting the harness).
  • lnk-<name> traces: A JSON list of paths (~/... or absolute, {uid} for the user's id) the harness writes when it isn't in its home. Read by lnk agent <harness> show, lnk agent <harness> remove.

Any lnk-<name> answering the commands above is a harness: lnk agent use <name> runs it like Link Harness. A harness Link doesn't write lives in its own repository, and releases and signs itself:

  • Releasing: a GitHub release holding lnk-<name>-<target>.tar.gz for each target it runs on (aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-musl, aarch64-unknown-linux-musl; lnk on a machine whose target it lacks refuses it, and so does a box of that processor), each with lnk-<name> alone; a SHA256SUMS of them, signed with the repository's own Ed25519 key (ssh-keygen -Y sign -n link-release -f <key> SHA256SUMS, giving SHA256SUMS.sig); and the key's public half as release-key.pub. lnk-<name> --version prints lnk-<name> <version>.
  • Installing: lnk plugin add https://github.com/<owner>/<repo> takes the latest release, shows the key and asks, then pins both (Plugins). It installs only an adapter answering contract with a version.
  • Updates: lnk upgrade takes each one's latest release, signed by the pinned key, when it's newer.
  • Listing and boxes: lnk agent use lists it with where it came from (lnk plugin list --json), and lnk agent move adds it on the box from the same repository, with the same key.
  • Without a release, a lnk-<name> you put on the PATH yourself works too, by name, unsigned, and yours to update and put on a box.

The smallest one answers contract, command (its Launch), health, needs and endpoint, and serves chat completions at that address: Link carries Telegram for it, and the sandbox, the model's key on the wire, moving and switching work as for any harness.

Memory engines (lnk-<engine>)

The contract is memory.rs. lnk agent memory use <engine> runs lnk-<plugin> (MEMORIES in the harness plugin maps link to link-memory; any other name is its own plugin, from Link or not).

  • lnk-<engine> install: Installs the engine, or leaves it; no questions. Run confined with HOME <state>, which it may write, and the network.
  • lnk-<engine> command <folder> <state>: Prints link_plugin::memory::Serve, {"argv", "allow"?, "models"?, "model"?}: the command serving MCP over stdio for <folder>, keeping what it keeps in <state>; the calls that only read, which don't ask (allow); whether it reaches this machine's model runtimes (models, which gives its sandbox network); and an embedding model to serve for it (model: lnk agent memory serve runs lnk model serve <model> --json outside its sandbox with each session, and gives the engine its URL as $LNK_MEMORY_MODEL, empty when no runtime here has it or it doesn't say where it listens within 10 s, so the engine starts well within a harness's wait for its tool). Every command of the engine's gets the agent's opinions for it as $LNK_MEMORY_SPEC: its spec's [tool.memory] as JSON, model the embedding model to rank by (none: by words only, no model printed) and tokens the most a search gives; an engine refuses one that isn't valid. Read by lnk-harness, which defines it as the agent's tool memory-<agent id> at every start, run by lnk sandbox run reading <folder> and the program itself and writing <state>, and gives the harness its address as $LNK_MEMORY and, to its adapter's configure, as memory.

Channel plugins (lnk-<channel>)

The contract is channel.rs.

  • lnk-<channel> connect --agent <name> [--taken <json>]: Asks for the channel's token (its LNK_<CHANNEL>_... variables, else on the terminal, questions on stderr), refuses a bot in --taken ([{"id", "agent", "machine"?}], the bots other agents have, here and running on boxes), pairs whoever sends the bot its one-time code, and prints link_plugin::channel::Connected on stdout: {"settings", "bot": {"id", "name", "team"?}}, settings the channel's section of link_plugin::harness::Settings, bot.id what another agent's connect refuses (a Telegram bot's id, a Slack or Discord bot's user id, the agent's WhatsApp or Signal number). Read by lnk agent connect (which stops the agent meanwhile, keeps the settings and starts it again).
  • lnk-<channel> describe --json: Prints link_plugin::channel::Describe: {"title", "secret_fields"?, "linked", "bridged", "cli"?, "hosts"?, "disconnect_hint"?}: its name as people write it, the fields of its settings a Mac keeps in the Keychain (as agent/<agent>/<channel>-<field>, _ as -; Telegram's bot_token as bot-token), whether its account is linked as a device (link, state), whether it carries messages (bridge), whether it installs a program for a harness speaking it (cli), the hosts a harness speaking it reaches, and what lnk agent disconnect says stays the user's. Read by lnk agent connect, which keeps it with the channel, so a plugin from outside Link needs nothing of Link's but PLUGINS to plug in.
  • lnk-<channel> cli install --home <dir> --json: A channel whose describe says cli (lnk-signal) only. Reads its section of the settings on stdin (Signal's {"account", "allowed_users"}), installs the program a harness speaking it runs into that harness's home <dir>, and prints {"program"}, its path (<dir>/.signal-cli/signal-cli). Read by lnk-harness, which runs it in the harness's sandbox, reaching the adapter's hosts, before configure, and hands the adapter the path in the section ("signal": {..., "program"}).
  • lnk-<channel> cli link --home <dir> [--again]: The same channels only. Links the account in <dir> with that program, its QR code on the terminal; exits 0 once linked (--again: anew), 3 when it isn't and there's no terminal. Read by lnk-harness in place of the adapter's link for such a channel, in the harness's sandbox.
  • lnk-<channel> rule --json: Prints link_plugin::channel::Rule, or null when the harness holds the channel's secret itself: {"field", "root", "host", "paths": [{"prefix", "rest"}], "header"?, "refuse"?, "methods"?, "public"?}. field is the field of the channel's settings holding the secret, and root the field a harness reads the service's address from; the rest is the proxy's rule for the secret (link_plugin::sandbox::SecretRule: {secret} where the secret goes in a path's prefix, else in header; rest name or path; refuse paths compared without case; public the separator ending the secret's part that isn't secret). Read by lnk-harness with keys on the wire (lnk agent keys wire): the harness is handed a placeholder in field and its proxy's secret_url in root, and the proxy the secret. A plugin that can't answer keeps the agent from starting, so its secret never reaches the harness.
  • lnk-<channel> bridge: A channel whose describe says bridged only. Reads link_plugin::channel::Bridge as one JSON line on stdin ({"agent", "agent_id"?, "endpoint", "key", "proof"?, "model", "settings", "state"}, agent_id sent as Link-Agent-Id, spec the agent's opinions for the channel, {"working"?, "stream"?, "pace"?, "steered"?} (its spec's [channel.<name>] over [channels]): a reaction while it answers, whether the answer is shown as it's written, and how often, never more often than the platform documents a bot may write (Telegram: a second in a chat, 3 in a group; Slack: 1.2 seconds; Discord documents none, so the bridge follows the rate limits it's answered with); left out, the bridge's own way, proof whether the endpoint proves it's the harness first, settings the channel's section, state a folder for this channel alone, mode 700, which no harness reaches), then carries messages until stdin closes: each direct message from an allowed user goes to the harness's endpoint as above (link_plugin::channel::ask, which asks for the proof), and the answer back. Read by lnk-harness (lnk agent bridge, and lnk agent start --foreground), for a harness with an endpoint.
  • lnk-<channel> send: A channel that can send a message nobody asked for (lnk-telegram, lnk-slack, lnk-discord). Reads link_plugin::channel::Sending as one JSON line on stdin ({"bridge", "chat", "text"}: the same Bridge, the chat as its thread names it after the channel, telegram:42's 42) and sends text there: only to a chat of the owner's alone (Telegram: an allowed user's own chat; Slack: the owner's direct conversation with the app, dm_channel; Discord: the owner's direct messages, dm_channel; an app or bot connected before Link kept it sends nowhere until connected again), never a group's, and exits nonzero when it can't. Read by the bridge's way in (below), for an answer to a schedule or a subagent.
  • The bridge's way in: for a harness whose contract says from, lnk agent bridge listens at ~/.config/lnk/agents/<agent>/way-in.sock (600, link_plugin::channel::way_in) for messages from others, one JSON line each (link_plugin::channel::Firing, {"thread", "from", "text", "answer", "hops", "tools"?}, answer one of "chat", {"thread": "<name>"} or "nowhere"). It sends each to the harness's endpoint with Link-From and Link-Hops, and Link-Tools when it has tools (refused, unsent, for a harness whose contract doesn't say tools), then the answer where it goes: the chat its thread is (lnk-<channel> send), another conversation as a message from the same sender, one hop further (a subagent's answer, to its parent's), or nowhere; and answers with one line, link_plugin::channel::Fired ({"answer", "error"?}), once it went. It runs the agent's scheduler's runner (lnk-link-scheduler run) beside its channels, again each time it stops. The scheduler's tool is scheduler-<agent id>, its runner and tool given the agent's spec's [tool.scheduler] as flags (--per-day, --subagents, --hops, --late, --zone, and the runner's --timeout, from firing); with ask, its task, schedule and cancel aren't allowed when it's defined, so each asks first (lnk sandbox asks).
  • lnk-<channel> link [--again]: A channel whose describe says linked (lnk-whatsapp, lnk-signal) only. Reads the same Bridge on stdin and links the channel's account as a device into its state, from a QR code on stdout. Exits 0 at once when it's linked already (--again: links it anew), and 3 when it isn't and stdout is no terminal. Read by lnk-harness (lnk agent connect, lnk agent start), for a harness with an endpoint.
  • lnk-<channel> state: A linked channel only. Prints link_plugin::harness::State as JSON: what in its state folder is the link (~/-relative to that folder; Signal's account, WhatsApp's whatsapp.db), never a program it installed. Read by lnk-harness (lnk agent send), which carries those paths as channels/<channel>/..., named in the move's manifest (channels).
  • lnk agent bridge (hidden): Carries agent -a's channels as ~/.config/lnk/agents/<agent>/bridge.json says ({"endpoint", "key", "proof", "channels", "from", "recover", "tools"}, written by lnk agent start), a lnk-<channel> bridge for each, started again when one stops. Read by the agent's bridge service (link.bridge.<agent id>, lnk-bridge-<agent id>.service).

Cloud adapters (lnk-<kind>)

What lnk-aws and lnk-gcp answer.

A cloud adapter's commands but connect each read one Request as JSON on stdin ({"account", "place"?, "id"?, "spec"?, "owner"?, "places"?, "public_key"?, "host_keys"?, "tags"?, "bucket"?, "labels"?, "image"?}, account being lnk cloud env's) and print one Reply as JSON on stdout; errors go to stderr with a nonzero exit, and a command the adapter doesn't have fails saying has no command there. A box is an Ubuntu LTS machine (spec.os) with a public address, reachable over SSH as ubuntu with spec.public_key, running spec.user_data (cloud-init) at its first boot. The contract's source is src/local/plugin/src/cloud.rs.

  • lnk-<kind> connect [--name] [--region] [--profile] [--key-id] [--project] [--zone] [--key-file]: Signs in to an account (the cloud's own login, reused, else asking on the terminal, questions on stderr), checks it, and prints its Account as JSON. Its env is the account's one sign-in, which every plugin acts as. On Google Cloud that is one credential file, GOOGLE_APPLICATION_CREDENTIALS: a service account's key (--key-file, also CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE), or your login's application default credentials. Read by lnk cloud connect.
  • lnk-<kind> storage: {"key": {...}, "env"?: {...}}: the account's own sign-in for its buckets, as rclone's settings for them (type included; never saved), and variables for the rclone run. An aws profile (env_auth, profile) or access key; a service account's service_account_file, or for a person's login env_auth and GOOGLE_APPLICATION_CREDENTIALS. Needs no CLI. Read by lnk bucket (each run on an aws or gcp bucket).
  • lnk-<kind> forget: {} once what the adapter kept for the account (Google Cloud: its credential file) is gone. Read by lnk cloud disconnect.
  • lnk-<kind> defaults: {"place", "size"}: where and how big a box is by default. Read by lnk box start.
  • lnk-<kind> sizes: {"sizes": [{"size", "cpus", "memory_mb", "gpus", "gpu_model"?, "arch", "price"?}]}: the machine types a box can be at the request's place, in the adapter's order of preference (general purpose, current generation, cheapest family first). gpu_model in Link's words (nvidia-l4); arch x86_64 or arm64; price dollars an hour, only from a rate the cloud gives. AWS lists describe-instance-types the region offers (describe-instance-type-offerings), Google Cloud the zone's machine-types list, with the GPUs built in, and each N1 machine again with the T4s the zone's accelerator-types list attaches (n1-standard-4.nvidia-tesla-t4.1, a name launch takes as its size); neither gives a price there. Read by lnk box sizes, and by lnk box start to match a spec's CPUs, memory and GPUs or for what a pinned size has, never for the default size; when it fails, its stderr is the error matching gives. An adapter without it makes a pinned or default size only.
  • lnk-<kind> launch: {"id"}: starts making a box from the request's spec ({"name", "id"?, "tags"?, "size", "user_data", "public_key", "os", "arch", "disk_gb", "disk_type", "spot", "gpus", "gpu_model"?}): its machine named lnk-<id> (lnk-<name> without one) and tagged with tags (lnk-box, lnk-machine, lnk-owner, and the spec's own). The machine fields default to the box Link made before specs (ubuntu-24.04, x86_64, 20, balanced, not spot, no GPUs), the only box lnk box asks of an adapter without sizes, which would ignore them; the adapter maps each to its cloud's (its image from a fixed table by publisher, NVIDIA's driver with gpus; balanced gp3 or pd-balanced, fast io2 or pd-ssd, at least the image's disk, saying so on stderr when that's larger; spot a persistent request or a spot VM, each stopping the machine when taken back) and refuses what it doesn't know (link_plugin::cloud::Spec::check), arm64 with GPUs among them; arm64 is Ubuntu's arm64 image. With image, an id image-list gave, the box's disk starts from that image instead, one of the account's own and ready (an AWS AMI --owners self, a Google Cloud image of the project), or the launch fails. Read by lnk box start, lnk box start --image.
  • lnk-<kind> wait: {"ip"} once the box runs. Read by lnk box start.
  • lnk-<kind> start: {"ip"}: a stopped box, running again. Read by lnk box start <box>.
  • lnk-<kind> stop, remove: {} once stopped, or gone with its disk (on AWS a spot box's request cancelled first). Read by lnk box stop, lnk box remove.
  • lnk-<kind> state: {"state", "stopped_by_cloud"?}: running, stopped, gone, ..., and for a stopped box whether its cloud stopped it taking a spot machine back rather than a person, when the adapter can tell (AWS by the instance's state reason; Google Cloud can't). Read by lnk box list --refresh.
  • lnk-<kind> describe: {"boxes": [{"id", "name", "machine_id", "place", "state", "ip"?, "size", "stopped_by_cloud"?, "spot"?, "disk_gb"?, "disk_type"?, "os"?, "arch"?}]}: the box at id as its cloud says it is, each machine field in launch's words where the cloud says it. Read by lnk box spec <box>.
  • lnk-<kind> hostkeys: {"host_keys"}: the box's SSH host keys (<type> <key>) as save-hostkeys kept them on it, else as its first boot printed them on its console (link_plugin::cloud::host_keys reads cloud-init's block); none until it has. Read by lnk box start, lnk box (a box found in the cloud).
  • lnk-<kind> save-hostkeys: Reads host_keys; {} once they're kept on the box's instance (Google Cloud metadata lnk-host-keys, an AWS tag lnk-host-key with the ed25519 key). Read by lnk box start.
  • lnk-<kind> list: Reads owner and places (none: everywhere); {"boxes": [{"id", "name", "machine_id", "place", "state", "ip"?, "size", "stopped_by_cloud"?}]}, every box tagged lnk-owner=<owner>. Read by lnk box list, lnk box start (a name no box of yours has).
  • lnk-<kind> add-key: Reads public_key; {"temporary"?} once the key lets ubuntu in: merged into the instance's keys (Google Cloud), or pushed with EC2 Instance Connect for 60 s (temporary: the caller appends it over SSH meanwhile). Read by lnk box (this computer's key, on a box found in the cloud).
  • lnk-<kind> tag: Reads tags; {} once added to the box's tags (labels). Read by lnk box list (a box from before tags).
  • lnk-<kind> label: Reads bucket and labels; {} once they're added to the bucket's labels (tags), the others kept. Read by lnk bucket (an agent's new bucket: lnk-agent, lnk-agent-name, lnk-owner).
  • lnk-<kind> buckets: Reads labels; {"buckets": [{"bucket", "place", "labels"}]}, Link's buckets (local-link-*) with every one of them. Read by lnk bucket find, keys, revoke.
  • lnk-<kind> bucket-key: Reads bucket; {"key": {...}}, rclone's settings (type included) for a new key reaching only that bucket's objects: an IAM user lnk-<bucket> with an inline policy (AWS), a service account lnk-<...> with roles/storage.objectAdmin on the bucket (Google Cloud). Its keys from before are deleted. Read by lnk bucket keys.
  • lnk-<kind> revoke-key: Reads bucket; {} once that user or service account is gone (gone already is done). Read by lnk bucket revoke.
  • lnk-<kind> image-save: Reads id (a stopped box), image (its name: lowercase letters, digits and dashes, link_plugin::cloud::check_image) and tags (lnk-image, lnk-owner, and lnk-hash for an image saved with a hash); {"images": [image]} once the box's disk is saved as image lnk-<image>, tagged with every tag given, and ready: an AMI and its snapshot, both tagged (AWS, create-image, waited for), an image of its boot disk (Google Cloud, images create --source-disk). Each image is {"id", "name", "place", "state", "disk_gb"?, "arch"?, "created"?, "hash"?}: its id in the cloud, its lnk-image tag, its region (empty on Google Cloud, where it's the project's), ready, pending or failed, its disk in GiB, x86_64 or arm64, when it was made, its lnk-hash tag. An adapter that doesn't say hash still saves the tag, and lnk box then finds no image there by its hash. Read by lnk box image save.
  • lnk-<kind> image-list: Reads owner and places (none: everywhere); {"images": [...]}, every image of the account's own tagged lnk-image and lnk-owner=<owner>, as image-save prints one. Read by lnk box image list (and --hash, its hash), save (a name no image of yours has), remove, and lnk box start --image and --image-hash (the newest ready image with that hash, by created); an adapter without it makes no image, and lnk box says so.
  • lnk-<kind> image-remove: Reads image (an id image-list gave), place and owner; {} once the image is gone with its snapshots (AWS deregister-image, delete-snapshot; Google Cloud images delete), or was already. Refused for an image not tagged as Link's for owner. Read by lnk box image remove.
  • lnk-<kind> cleanup: {}: deletes what the adapter made for boxes (a firewall rule, a key pair) once its cloud says no Link box uses it; else nothing. On AWS it first cancels Link's spot requests whose box is gone. Read by lnk box remove.