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 tolnk, andlnk <group> ...runs it, the waygitrunsgit-<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
authand not the tunnel's, because boxes need it too. The same holds forlink-pluginand 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,lnkand 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/: nothingcore/:packagesplugins/tooling/:packages,coreplugins/networking/:packages,core,plugins/toolingplugins/security/:packages,core,plugins/tooling,plugins/networkingplugins/cloud/:packages,coreintegrations/cloud/:packages,core,plugins/cloudplugins/storage/:packages,core,plugins/cloud,plugins/networkingplugins/compute/:packages,core,plugins/cloud,plugins/networking,plugins/security,plugins/toolingplugins/agents/: every group above butintegrations/cloudintegrations/agents/:packages,core,plugins/security,plugins/agentstests/: 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/orintegrations/, outside this layout; - a package under
plugins/orintegrations/that isn't in a domain folder; - a package under
plugins/that depends on one underintegrations/, 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.tomlowns 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 passshellcheck, 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 asklnk auth relayfor 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 inpackages/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>, likelnk 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).
The harnesses Link ships
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/) answeringinstallandcommandinlink_memory_contract. Declare its kind and requirements inplugin.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
contractandcallinlink_store_contract::store, checked withlink_store_contract::store::check(andcheck_jobs, for one that keeps a scheduler's jobs too). - Link's store drivers:
plugins/agents/stores/sqlite(lnk-sqlite) andintegrations/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, arelink_measure_contract::client. What a harness counts itself, it appends toLNK_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/cloudandplugins/compute/box. Neither knows which cloud, which is the adapter's job. A box is a machine runninglnk, 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 inplugin.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
lnkgroup): a new plugin, an owner-local member (Adding a plugin). - A new
lnkcommand: the plugin owning its group, with its route, help and requirements in itsplugin.toml. - What
lnk uninstallstops and deletes of a plugin: that plugin, answeringuninstall(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 aSecret, read withexpose()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 Link makes 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.