Architecture of a small core and its plugins

lnk is a small core that runs a plugin for each part of Local Link, and every agent runs in Link's sandbox, which lets it touch only what you allow. This page is how the code is laid out and where new code goes, and Mission and Decisions say why it's built this way.

The core and its plugins

A user installs the core alone with install.sh, and lnk up asks which plugins to add (Plugins).

  • A plugin is its own program, lnk-<name>, next to lnk, and lnk <group> ... runs it, the way git runs git-<name>. What a user installs is exactly what runs, and one plugin can't break another.
  • The core only installs, runs, updates and removes plugins. New behavior goes in a plugin, never the core.
  • The core knows each installed program by what it describes through the versioned plugin protocol: its commands, kind, requirements and documentation. The core's own settings: Configuration.
  • The command line routes an operation to the program that declares it, the release registry supplies installation choices and upgrade dependencies, and removal combines the state declarations installed programs return.
  • Plugins use each other only by running lnk <command> and reading a documented output (Plugin Contracts). They never link each other's code or read each other's files.
  • Each of Link's plugins ships as its own signed archive at the core's version. So do Link Harness, Link's tools (Link Memory) and the OpenClaw, Hermes and DeepSeek Harness adapters, built from their own members. A harness from outside Link ships from its own repository, signed by its own key.
  • Code goes in the plugin whose purpose it serves, and a plugin's purpose is its name. Before adding code to a plugin, ask whether you would still use this code without installing the plugin it serves, and whether this plugin has to know another's files, API or quirks. If either is yes, the code goes in the plugin it serves.
  • Code several plugins need, and none owns, is a plugin of its own, which is why the login is auth and not the tunnel's, because boxes need it too. The same holds for link-plugin and the core. The test worked through is Where code goes: the seam test.

Layout

Every component has one predictable home, set by what it provides, never by who uses it. A component's folder says what it does and who owns it, so you can find it without reading a Cargo.toml. Why the tree, files and crate names are this way: Decisions.

  • core/: the framework, lnk and what it needs to install, run and update plugins: the CLI (core/cli) and the plugin protocol (core/plugin).
  • packages/: shared libraries that two subjects or the core use.
  • plugins/<domain>/: Link's own capabilities: a domain, then the plugin, then its contract and implementations.
  • integrations/<domain>/: integrations with third-party products, in the same domains, beside the plugin whose contract they implement.
  • docs/: the pages about the whole repository: the CLI reference, specs, credentials, architecture and contributing.
  • tests/, scripts/: cross-subject suites, fixtures, demos and release automation.
  • web/: the npm/Turbo workspace: the website and every JavaScript package.

The domains are agents, cloud, compute, networking, security, storage and tooling. A page about one plugin or package lives in that folder's docs/en/.

Layers

Each group folder is a layer: packages/, core/, tests/, and each plugins/<domain>/ and integrations/<domain>/. Its layer.toml has one key, depends_on, which lists every group folder the layer is built on. It lists each one directly, never one implied through another. A crate depends only on crates of its own group, or of a group its layer.toml lists. Inside a group, the crates' own manifests decide, and Cargo refuses a cycle. The layers, lowest first:

  • packages/: nothing
  • core/: packages
  • plugins/tooling/: packages, core
  • plugins/networking/: packages, core, plugins/tooling
  • plugins/security/: packages, core, plugins/tooling, plugins/networking
  • plugins/cloud/: packages, core
  • integrations/cloud/: packages, core, plugins/cloud
  • plugins/storage/: packages, core, plugins/cloud, plugins/networking
  • plugins/compute/: packages, core, plugins/cloud, plugins/networking, plugins/security, plugins/tooling
  • plugins/agents/: every group above but integrations/cloud
  • integrations/agents/: packages, core, plugins/security, plugins/agents
  • tests/: every group above

How the layers are held

The docs checks hold every crate to them (tests/docs, the layers rule): in Cargo, normal and build dependencies alike, in a plugin it runs (lnk <group> ..., after link_plugin::plugins::ensure installs it), and in a question it answers. A plugin learns what a plugin above it has only by asking: the one above answers the question in its manifest, in the asking layer's types (Questions). A part that would cross moves to where it belongs; a depends_on is never added for it (why).

What doesn't belong

None of these belongs in the tree:

  • a folder at the workspace's root, or under plugins/ or integrations/, outside this layout;
  • a package under plugins/ or integrations/ that isn't in a domain folder;
  • a package under plugins/ that depends on one under integrations/, since Link's own capabilities never need a third-party product.

Workspaces

Each Cargo workspace owns one manifest, lockfile, toolchain, build profile and target directory. The default members are only core/cli and core/plugin. Other subjects build when selected. Documentation lives in each owner's docs/en; its relative topic path is its canonical website and offline manual path.

Dependency rules

What a crate may depend on, and where what it declares lives:

  • Cross-owner business logic uses the provider's owner-local contract. A contract's transitive graph contains no implementation or fixture. Generic core code and neutral libraries contain no domain contracts.
  • Standalone composition declares exact importing modules and optional hosted features in Cargo metadata, rather than being a blanket exception for an application.
  • The core links generic protocol and neutral packages. Its registry, routing, installation menu and removal behavior derive from descriptors.
  • Neutral top-level packages have multiple subjects using them or a core consumer. Helpers owned by one subject stay beneath that owner.
  • Cargo owns identity, binaries, versions and compile dependencies. plugin.toml owns detailed command help and operational requirements. Typed option schemas are imported; Markdown owns authored explanations.
  • Runtime command requirements are distinct from compile dependencies. Executing another program names its command and typed option bindings; those requirements propagate on the executing platform.
  • Tests never provision an engine just because the adapter can install it. Every checkout owns its build output.
  • A released crate never builds with one a release holds back (released = false), though its tests may.

See Components for the schemas and commands. A new crate goes in the workspace's members; shared local paths are declared once in workspace.dependencies.

Where does new code go?

Prefer a new plugin over growing the core or another plugin, and keep crates small and names literal. Adding a plugin covers how, and each list below says, for what you're adding, the folder it goes in.

The relay, tunnels and accounts

The relay, its wire and its hosting, a user's tunnels, and the login every plugin asks for:

  • A message or field on the agent↔relay wire: plugins/networking/relay/protocol (see Changing the protocol).
  • Public edge behavior (routing, auth, limits, domains): plugins/networking/relay/server.
  • Hosting the relay (TLS, Caddy, systemd, updates, backups): plugins/networking/relay/deploy. Scripts stay idempotent and pass shellcheck, and those run on a Mac work with bash 3.2.
  • Connection behavior (reconnects, multiplexing, several services per connection): plugins/networking/relay/client.
  • Your login to the relay (lnk auth: logging in, its token, who you are): plugins/networking/auth. Other plugins ask lnk auth relay for it, never its file.
  • A route of the account service's API, or a field of one: its spec first, plugins/networking/auth/docs/en/tooling/auth/account/api/openapi.yaml; then its function in packages/api (link-api), whose types are made from the spec at every build. The client holds no credential: its caller hands it one.
  • Tunnel behavior on the user's side: plugins/networking/tunnel.
  • A new kind of thing to expose (whisper, ...): the owning plugin serves it on 127.0.0.1 and opens a tunnel to its port, lnk tunnel open <port>, like lnk model share.

Models and harnesses

The models an agent runs on, and the harnesses that run it:

  • Finding, serving or managing models: plugins/agents/model.
  • Installing, configuring, running or switching harnesses: plugins/agents/agent.
  • Link Harness (its turns, its conversations, its window, calling tools): plugins/agents/link/, beside the core in the public workspace, with its pages (plugins/agents/link/docs/en/agents/link/). The core's tests run on the mini harness (tests/fixtures/mini-harness), and every harness passes the conformance kit (plugins/agents/harnesses/conformance).
  • What the adapter contract asks of every harness: plugins/agents/harnesses/contract (link_harness_contract::harness), answered first by the mini harness and checked by the conformance kit.

Support for one harness

Support for one harness (OpenClaw, Hermes, ...) is its own repository, not this one: an lnk-<name> answering the adapter commands in link_harness_contract::harness (with contract), released and signed with its own key, and added with lnk plugin add --url <url>. It runs sandboxed with HOME set to the harness's home folder, so it writes everything under link_plugin::home_dir(). What is specific to it is its adapter's answer through the contract (Adding a harness).

OpenClaw, Hermes and DeepSeek Harness ship with Link, so they're named in KNOWN (harness settings) and nowhere else under src/ (architecture.rs).

An agent's moves, exits and connections

Where an agent's traffic leaves from, how it moves between machines, and the connections people text it on.

Where an agent's traffic leaves from

Where an agent's traffic leaves from (lnk agent exit), and the filtering proxy: plugins/agents/agent decides which upstream (settings.upstream, moving.rs). The sandbox plugin holds the proxy (proxy.rs): a sandbox in its own network, with Link's proxy as its only way out.

The exit

The exit plugin (plugins/networking/exit) makes this computer a machine's exit with lnk exit start --on <machine>, held by the agents using it, and for a box it goes through lnk box forward, which is SSH home. The exit is always the user's own computer.

Moving an agent between machines

Moving an agent (lnk agent move) is plugins/agents/agent (moving.rs, transfer.rs, hosted.rs), plus each harness adapter's state; the stream's format, each file's hash and what the other end holds (have) are in link_agent_contract::moving. Two phases: a copy while the agent answers, then a switch of what changed, the agent paused (lnk agent pause).

How a move travels

One machine's lnk agent state-send is piped into the other's receive over SSH (lnk box run), or into a cloud account's adapter when it hosts agents (agent-take, agent-give in link_cloud_contract), never through the relay.

Support for one connection

Support for one connection (Telegram, Slack, Discord, ...), where you talk to your agent, is a new plugin in a folder of its own beside the core, integrations/agents/connections/<connection>/ (lnk-<connection>), answering connect, describe and rule in link_connection_contract, which make the bot, pair its owner and say what the harness keeps of it. Declare its kind in plugin.toml and update each adapter that speaks it. The harness plugin names no connection (Adding a connection).

A connection in the core's tests

A core build compiles and tests no connection (why): the core's own tests that need one run the mini connection (tests/fixtures/mini-connection, lnk-minichat) against its fake service (link_harness_conformance::minichat).

Tools, memory and stores

The tools an agent calls, its memory, and where its conversations are kept:

  • A tool for agents (an MCP server any harness calls), or Link Memory (what it indexes, its search, its MCP tools): plugins/agents/tools/, with pages beside each tool. lnk-link-memory serve <folder> is an MCP server over stdio that reads only its folder and never follows a link out of it.
  • Support for one memory engine: a plugin of its own (Link's is in plugins/agents/tools/) answering install and command in link_memory_contract. Declare its kind and requirements in plugin.toml (Adding a memory engine).
  • Where Link Harness keeps conversations, a store driver (SQLite, a database off the machine): a plugin of its own answering contract and call in link_store_contract::store, checked with link_store_contract::store::check (and check_jobs, for one that keeps a scheduler's jobs too).
  • Link's store drivers: plugins/agents/stores/sqlite (lnk-sqlite) and integrations/agents/stores/gcs (lnk-gcs), declared as store plugins. An agent's spec names it ([storage] conversations).

The sandbox and its bridges

What one harness may do (its folders, ports, which permissions it has) stays in plugins/agents/agent. Anything else that should run confined writes a Spec, makes a Confinement from it (Confinement::of) and calls link_sandbox_client::wrap.

What a sandbox can and can't reach

The Seatbelt and bubblewrap rules, permissions, the probes (lnk sandbox check) and lnk sandbox run are plugins/security/sandbox; the rules themselves, per OS, are in its wall plugins/security/sandbox/confine (linux/, macos/). The policy and permissions a caller passes are link_sandbox_client.

A bridge out of an agent's sandbox

A bridge is a program outside the sandbox doing one kind of thing for the agent. It goes in the plugin that runs it, on the server every bridge runs on (link_bridge::serve), saying only what it does with a call and within what limits; its tests run link_bridge::conformance::check against it, listening. What every bridge is, who's calling, its proof, the error envelope, the server and that test, is plugins/security/sandbox/bridge (link-bridge).

Measures, files and clouds

What's measured, the files sent to the user's clouds, and the cloud accounts and boxes:

  • Measuring agents and this machine (lnk measure: what's read from outside, the watcher, what it keeps, the export): plugins/tooling/measure. The measures and how they cross programs, OTLP, are link_measure_contract::client. What a harness counts itself, it appends to LNK_MEASURES (Link Harness, plugins/agents/link/, for its own).
  • Files to the user's clouds (lnk bucket): plugins/storage/bucket. Cloud keys stay on the machine, and files never pass through the relay.
  • Connecting cloud accounts (lnk cloud), and machines in them (lnk box): plugins/cloud/cloud and plugins/compute/box. Neither knows which cloud, which is the adapter's job. A box is a machine running lnk, and what runs on it belongs to the plugin that runs it locally.
  • Support for one cloud (AWS, Google Cloud, ...): a new plugin, an owner-local member answering the adapter commands in link_cloud_contract. Declare its kind, commands and requirements in plugin.toml (Adding a cloud).

Plugins, commands and shared code

A new part, a new command, and what every plugin shares:

  • A new part of Link (a new lnk group): a new plugin, an owner-local member (Adding a plugin).
  • A new lnk command: the plugin owning its group, with its route, help and requirements in its plugin.toml.
  • What lnk uninstall stops and deletes of a plugin: that plugin, answering uninstall (link_plugin::uninstall). The core only asks.
  • Something every plugin needs: core/plugin, if it is small and truly shared.
  • How a secret is held (never printed, zeroed when dropped): packages/secret-value (Secret). A field holding a secret is a Secret, read with expose() only where it's used.
  • A test spanning server, local and demo: tests/e2e/tests/.
  • Website: web/apps/home, within its web workspace. It talks to the relay only over HTTP.

A call to a service of Link's own (the account service, a relay's API) or one it uses (a cloud's, a chat's) goes through link_plugin::fetch (packages/fetch, link-fetch), the only place Link's own code builds an HTTP client: the request a Serialize type, the answer a Deserialize one, its credential a Secret, and what differs a named rule of the client's (Link's own calls). What an agent fetches (a page, an MCP server) and a model's provider don't go through it, and are listed for link_fetch::audit, which every folder's tests run.

Changing the protocol

Prefer additive #[serde(default)] fields, which need no version bump. Anything breaking bumps link_relay_protocol::VERSION, and the relay serves agents from MIN_AGENT_VERSION up; older ones are told to upgrade. The details and tests are in the relay's protocol.

Principles

What Link holds to in every part:

  • Every engine is swappable behind a documented contract. A harness is any lnk-<name> answering the adapter commands, so closed code stays in its own repository, and Link never names it.
  • Link owns what stays the same when the harness changes. That is settings, the files folder, the connections, switching, and which machine runs it. A harness that speaks a connection keeps speaking it, and for one that speaks none, Link carries the messages.
  • Work runs where it belongs. A box routes, a model cloud thinks, a bucket remembers, a scheduler wakes. Nothing that belongs elsewhere runs on the box.
  • Link's harness runs no code. Tools are MCP servers outside it, sandboxed, in any language, and shared by every harness. Keys never reach it, because the proxy adds them.
  • Turns hold no state between them. Any process on any machine can take the next turn.
  • Conversations are append-only. They are an event log that is never rewritten, and the prompt is sent the same way so the provider's cache holds. There is one layout on disk and in a bucket, in plain files a person can read, behind a storage seam. Tokens are always recorded, and cost only from a real rate.
  • Build the seams, not the implementations. An interface has one implementation, and the others aren't built until something needs them.
  • Written for the densest deployment, run at the safest. Code is written as if many agents share one process, with the agent always an argument, every path from one function, and a test that one agent can't reach another's. It runs one process per agent.

Docs

  • Security: How Link protects the people who use it, what it doesn't protect against, and where each part's details are
  • Decisions: Why the repository's tree, its layers, its files' sizes and its crates' names are what they are