Skip to content
Horon

How plugins fit together

8 min readplugins, architecture

A plugin is a native library, not a script#

In Horon, a plugin is a compiled library the agent loads into its own process. The host is the horon-agent binary. The plugin is a cdylib, a shared library with a C entry point, built separately and shipped as a directory the host installs as one unit. There is no script to interpret and no configuration language deciding what it may do.

The two sides meet through one exported symbol, horon_agent_plugin_get_api_v1. The host calls it, the plugin fills in a table of function pointers, and every later call goes through that table. The contract is a C ABI plus a JSON wire format, not a Rust trait object, because host and plugin are separately compiled binaries that cannot share a Rust layout at build time.

The first call is deliberately dull. The contract makes the handshake pure metadata: no instance, no login, no socket, no background loop, no account refresh. A plugin that touches the network or the disk merely because its library was loaded has already broken the contract. That is what lets the host ask what a library is without trusting it with anything yet.

The handshake settles compatibility before anything runs#

The table has nine entries: create, describe, start, next, cancel, release, shutdown, destroy, and free_buffer. It also states the ABI major, the ABI minor, the wire schema, and the features the library provides. On a 64-bit target the whole table is 104 bytes, and a host built against a newer header reads only the prefix it knows.

Both sides answer in a fixed order: null pointer, a struct shorter than the required prefix, a mismatched ABI major, a mismatched wire schema, a table with too little capacity, a required feature the library does not provide. Each refusal is its own status code. A plugin built for ABI 2 gets ABI_MAJOR_MISMATCH before an instance exists.

Compatibility is decided by target, ABI major, host API minor, wire schema, and required features. Version strings, compiler versions, and lockfiles are not part of it. A macOS arm64 bundle does not load on Linux x86_64, and the refusal comes from the bundle metadata check rather than the ABI.

What the host actually hands over#

When the host creates an instance it passes four things: a JSON configuration blob, a directory the instance may write to, a directory it reads configuration from, and the features enabled for that instance. That is the entire grant. No file handle, no shell, no path to your home directory, and none of the agent's own tools cross the boundary.

The mail plugin shows how literal this is. Its create step copies the provider to serve, the config root its credential store reads, and the directory attachments are written to, and it never looks for a user level configuration directory on its own. Creating, loading, or describing an instance performs no login, no OAuth start, no send, and no upstream connection.

A tool call is what a request looks like on the wire. The host sends {"kind": "tool_call", "name": "mail__whoami", "arguments": {}}, and the plugin answers with the product's own result envelope in a tool_envelope frame, then exactly one terminal frame. Frames carry a sequence number starting at zero with no gaps, so a dropped or reordered frame is an error.

Where the permission boundary really sits#

The boundary in the contract is narrower than a plugin system usually gets credit for. A plugin is handed two directories and a request body. It is not handed your disk. It does not decide which folder its credentials come from, and it does not get the agent's tool set. If a plugin can do something, it is because someone installed it and a product exposed that operation.

That grant is not a sandbox, and the contract says so in plain words. A plugin runs inside the host's address space. Null and length checks catch honest mistakes, not a hostile library. A build with panic set to abort, a double free, or a segfault ends the whole agent process, and no ABI can verify that a pointer names mapped memory.

What the host does enforce is cooperation. A request or frame above 64 MiB is refused, a queue above 32 frames returns busy instead of dropping work, one instance admits 64 active operations, one plugin 64 and all plugins 128, and the copy budget is 128 MiB. They are payload budgets, not a memory ceiling.

Installing one today is a command, not a click#

There is no store, no marketplace, and no one click install. A plugin is installed from a bundle directory on your machine, by name, on the command line. horon-agent plugins install ./plugin-bundle copies a validated bundle into the agent home, and list, enable, disable, and status cover the rest.

Install writes bytes and runs nothing. The bundle is verified, its files are copied into a staging directory inside the install root, the staged copy is verified as a whole, and only then is staging renamed onto its final name. An interrupted install leaves the previous version untouched. A version already installed is never rewritten: the same bytes report already installed, and different bytes under the same version are refused.

Installs land under native-plugins, then the plugin id, the product version, and the target, inside the agent home. Every ancestor has to be a real directory, so a symlinked plugin or version directory is refused rather than followed. horon-agent plugins doctor <id> is the step that actually loads the library, in a validation child process, so a plugin that fails hard ends that child instead of the agent.

What a bundle has to prove before it ships#

A bundle is a directory holding plugin.json, a checksum file, the license, the library under lib, a provenance record, and any declared sidecars. plugin.json is the only file the host parses, and it is read strictly: every defined field present and well formed, no unknown field at any depth, no self contradiction. Build metadata lives in the provenance record, which plugin.json declares as an asset with its own checksum.

The bytes being present proves nothing about how they were compiled, so a release bundle has to earn its evidence. Either the build ran the declared cargo command and recorded the invocation, or a recorded artifact binds the exact library by hash, size, target, profile, and feature set. A prebuilt library with neither is refused, because nothing then separates a release artifact from a QA one.

The feature graph is resolved, not just requested. A crate whose defaults pull in a QA provider can compile it in even when the release request names a narrower set, a defect the mail workspace hit. Release bundles record the resolved graph, and publication refuses a bundle with none or one containing a QA provider. The claimed feature list is also compared with the advertised one, and any difference is refused.

What is not built yet#

The honest state: plugins are installed and enabled from the command line, and that is the only path today. There is no store, no marketplace, no one click install, and no download from a registry. Release publishing is not switched on. The release declaration marks its destination unconfigured, so every publish step is skipped until two repository variables are set: one turns publishing on, the other names where bundles go.

A bundle you cannot build yourself is therefore a bundle you cannot install, unless someone hands you the directory. The mail bundle is installed from a local artifact by design, and its package publishing is disabled. If you want a product's plugin today, the answer is a directory on a disk, not a download link.

Some things are out of scope rather than unfinished. ABI 1 has no hot reload and no cross target portability. No memory sandbox is a design statement, not a to do item. The desktop app is not released, so there is no screen to install a plugin from either. What exists is the contract, the host, three product plugins, and a command line that installs them.