Files
sausage/PROPOSAL.md
T
surma 2415bb361f
ci / check (push) Successful in 2m17s
PROPOSAL.md: plugins, processes, and JSON-RPC
The design from the redesign discussion: a process per call, JSON-RPC on
stdin and stdout, a virtual /proc, /self, and /run, TCP endpoints, locks,
a central journal, generated imports with interface checks at activation,
and the SDK with examples.
2026-10-22 20:58:30 +01:00

28 KiB

Proposal: plugins, processes, and JSON-RPC

Status: a proposal on the branch plugins. When we accept it, its content goes into PROTOCOL.md (the contract) and DECISIONS.md (the reasons). Then this file goes away.

1. Why

Sausage was a world of actors: long-running programs that send each other fire-and-forget messages through a broker. Agents found this model hard.

  • A reply was a new message. The agent had to match reply_to values, wait with wait_seconds, and sort "other messages".
  • Mailboxes, subscriptions, taps, watches, versions, and restarts were all part of the contract.

This proposal replaces the model with three ideas that models already know:

  1. JSON-RPC calls. A call has a result, like an LLM tool call. LSP and MCP use the same protocol.
  2. Processes. Each call to a plugin starts a new WASI process. WASI has no threads, so processes are the unit of parallelism. Calls are asynchronous, so one process can make many calls at the same time.
  3. Files to inspect, calls to act. The core shows its state as virtual files in /proc, like Plan 9 and Linux. Each change of state is a call.

2. Terms

Term Meaning
world The folder that a sausage host runs. It is / for every process.
core The host. It runs processes, routes calls, serves the virtual files, and keeps the journal.
plugin A folder /plugins/<name>/ with a plugin.wasm. A plugin does not run by itself.
process One run of a plugin. Its ID is <plugin>-<uuidv7>, for example notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34.
call A JSON-RPC request. notes.add calls the method add of the plugin notes.
interface A set of methods. A plugin has one main interface. It can also declare endpoint interfaces.
endpoint An interface that a running process serves on a TCP port. An endpoint file tells others where it is.
import An interface of another plugin that a plugin uses. The SDK generates it from that plugin's description.
provider, consumer The plugin that has an interface, and a plugin that imports it.
journal The central log of the world.

3. The world folder

<world>/
  plugins/<name>/
    plugin.wasm               the current version
    plugin.prev.wasm          the version before, for `core.revert`
    source/                   the source. `cargo.build` builds it from here.
      imports/<plugin>.json   descriptions of the imported plugins
  state/<plugin>/             persistent state of a plugin, by convention
  state/core/journal.sqlite   the journal
  config/                     global configuration
    boot.json                 calls that the core makes at start
    agent.json                API, base URL, model, and token of the agent

Every process can read and write all of it. There are no permissions.

4. Calls

4.1 The protocol

  • JSON-RPC 2.0 on stdin and stdout, one message per line, as in MCP's stdio transport.
  • params is always an object. Batches are not supported.
  • A line on stdout that is not a JSON-RPC message goes to the journal. The core continues at the next line. The SDK writes a newline before each message, so a print! without a newline cannot damage a message.
  • Each line on stderr goes to the journal.

4.2 Method names

  • A method name has the form <target>.<method>. The core splits it at the first ..
  • Plugin names use a-z, 0-9, -, and _. The name core is reserved.
  • notes.add starts a new process of notes and sends it the request add. notes.rpc.discover sends it rpc.discover.
  • core.<method> is a method of the core (section 9).

4.3 What a call does

  1. The core gives the call a UUIDv7 and starts the process <plugin>-<uuid>.
  2. The core writes the request to the stdin of the process. The request ID is the process ID.
  3. The process writes the response to stdout. The core sends the response to the caller, with the caller's request ID.
  4. If the process ends before it answers, the caller gets the error -32002. Its data has the exit status and the last journal lines of the process.

A process can write calls to stdout at any time, to the core and to plugins. The responses come on stdin. A process can have many calls in flight.

4.4 Detached calls

core.call {"method": "www.start", "params": {...}, "detach": true} makes the call and returns its result. The new process does not end with its caller.

Sent as a notification (without an ID), the same core.call starts the process and forgets it.

4.5 Notifications to the caller

A process can tell its caller about progress. It sends the notification core.notify {"name": "text", "params": {...}}. The core gives it to the caller as core.notify {"call": <ID of the caller's request>, "name": "text", "params": {...}}. After the call has ended, the core drops such notifications.

So a process never needs to know who called it.

4.6 Lifetime

  • A process ends when main returns. The core keeps its stdin open until then.
  • EOF on stdin means "stop now".
  • core.stop {pid} closes stdin, waits 5 seconds, and then kills the process. core.kill {pid} kills it at once.
  • When a process ends, the core stops each of its attached children with core.stop.
  • A detached process has no parent. Only core.stop, core.kill, and the end of the host stop it.
  • At host shutdown, the core stops every process.

Note: on Unix, children outlive their parent. Here, an attached child ends with its parent. Only detach makes a process independent.

4.7 Environment and network

  • SAUSAGE_PID: the process ID.
  • SAUSAGE_PLUGIN: the plugin name.
  • SAUSAGE_PARENT: the parent: console, core, or a process ID.

Every process gets the network: outgoing HTTP and HTTPS through wasi:http, and TCP, UDP, and DNS through wasi:sockets.

4.8 Errors

The standard JSON-RPC codes apply. The core adds these codes:

Code Meaning
-32001 There is no such plugin.
-32002 The process ended without an answer. data has the exit status and the last journal lines.
-32003 The activation was rejected. data has the reasons.

5. The filesystem

Each process gets four preopens:

Path Backend Access
/ the world folder read-write
/proc virtual, served by the core read-only
/self virtual: the folder /proc/<own pid>/ read-write
/run virtual, shared, in memory, empty at each host start read-write
/proc/
  core/
    PROTOCOL.md          the contract
    graph.json           plugins, imports, and the result of each check
    graph.dot            the same graph for Graphviz
    locks.json           held locks and waiting processes
    plugins/<name>/
      discover.json      the rpc.discover document of the current plugin.wasm
      client.rs          the Rust code that import!("<name>") generates
  <pid>/
    info.json            plugin, method, params, parent, detached, start time, state
    log                  the journal lines of this process, live
    <files>              the files that the process writes to /self

Rules:

  • A process writes its own folder only through /self. Writes through /proc always fail.
  • In a process folder, the names info.json, log, and fs belong to the core. A write to /self/info.json fails.
  • The folder of a process goes away when the process ends. Its files are in memory. Large or persistent data belongs in /state.
  • Do not send a /self path to another process. There it means a different folder, like localhost on another machine.

Principle: inspect through files, act through calls. A file read is cheaper than a call, and every language and tool can read a file.

6. Endpoints

  • listen(path, interface) binds a free port on 127.0.0.1 and writes an endpoint file at path. The file has one line: the address, the owner, and the interface. Example: tcp:127.0.0.1:41234 www-0197… control.
  • Usually the path is in /self. A singleton uses a fixed path in /run, for example /run/secrets.
  • connect(path) reads the file. It checks that /proc/<owner> exists and that the interface is the expected one. Then it connects. So a stale file or a reused port cannot connect a client to the wrong process.
  • JSON-RPC runs on the connection, one message per line.
  • listen accepts only interfaces that the plugin declares (section 10.1).

7. Locks

  • core.lock {"path": "/state/notes/notes.json"} returns when the process holds the lock. Processes that wait get the lock in FIFO order.
  • With "wait": false, it returns at once with {"held": true} or {"held": false}.
  • core.unlock {path} releases the lock. When a process ends, the core releases all of its locks, also after a crash.
  • Locks are advisory. The core does not block writes to the file: the path is only a name.
  • SQLite has no file locks in WASI. Hold the lock while the database is open.

8. The journal

  • An SQLite file at /state/core/journal.sqlite. Only the core writes it.
  • Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the stream (stderr, stdout, or core), and the line.
  • The core writes its own events too: start (with method and params), exit status, stop, and kill.
  • core.log {plugin?, pid?, since?, until?, grep?, limit?} queries the journal. /proc/<pid>/log shows the lines of one running process.
  • Later: structured logging, like tracing.

9. The core API

Method Params Result
core.call method, params, detach? the result of the method
core.notify name, params none (a notification)
core.stop pid {} when the process has ended
core.kill pid {}
core.lock path, wait? {"held": bool}
core.unlock path {}
core.activate plugin and wasm, or plugins: [{plugin, wasm}]. force? the new rpc.discover documents
core.revert plugin, force? like core.activate
core.log filters (section 8) journal entries
core.exit none {}. Then the host stops with exit code 75.

Everything else is a file.

10. Interfaces

10.1 rpc.discover

Each plugin answers rpc.discover with an OpenRPC document. The SDK generates it from the code, with all doc comments. It has two extension fields:

  • x-sausage-endpoints: the endpoint interfaces by name, each with its methods.
  • x-sausage-imports: the methods that the plugin uses from each provider, with their schemas.
{
  "openrpc": "1.2.6",
  "info": {"title": "www", "version": "0.1.0"},
  "methods": [{
    "name": "start",
    "summary": "Start the web server and return the ID of its process.",
    "paramStructure": "by-name",
    "params": [{"name": "port", "required": true,
                "description": "The TCP port to listen on, on all interfaces.",
                "schema": {"type": "integer", "minimum": 0, "maximum": 65535}}],
    "result": {"name": "pid", "schema": {"type": "string"}}
  }],
  "x-sausage-endpoints": {"control": {"methods": [{"name": "stats", "…": "…"}]}},
  "x-sausage-imports": {"notes": {"methods": [{"name": "add", "…": "…"}]}}
}

10.2 Compatibility

A provider can add methods, optional params, and result fields. For each imported method:

  1. The provider has the method.
  2. Each params value that the consumer can send is valid for the provider.
  3. Each result that the provider can return is valid for the consumer.

The same rules apply to endpoint interfaces. There are no version numbers. This check replaces them.

The check uses jsoncompat if it is good enough. Otherwise we write our own check for the subset of JSON Schema that schemars writes. An answer that the check cannot prove counts as a failure.

10.3 Activation

core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}:

  1. The core starts the file and calls rpc.discover.
  2. It checks the imports of the new version against the current providers.
  3. It checks each current consumer of the plugin against the new version.
  4. If all checks pass, plugin.wasm becomes plugin.prev.wasm, and the file becomes plugin.wasm.

If a check fails, nothing changes. The error lists each problem, with a counterexample where possible:

notes was not activated: 1 consumer breaks.
- agent imports notes.add(text: string) -> Added.
  The new notes.add has no param "text". It requires "body": string.
  Counterexample: {"text": "milk"} is not valid for the new params.
Update agent first, or activate both together.
  • plugins: [...] activates several plugins together. The checks then use the new set.
  • force: true skips steps 2 and 3.
  • core.revert {plugin} activates plugin.prev.wasm in the same way, and the current version becomes plugin.prev.wasm.
  • Running processes keep their version until they end. New calls get the new version.
  • A person can also copy a file to plugin.wasm by hand. That skips the checks (section 10.4).

10.4 The graph

  • The core keeps the rpc.discover documents of all plugins in memory, by file hash. At start, it gets them in the background.
  • A file watcher on /plugins/*/plugin.wasm updates them, for example after a copy by hand. Such a copy skips the checks. graph.json then shows each broken import, and the journal records it.
  • An import of a plugin that does not exist is "missing".
  • Untyped calls (call("x.y", ...)) are not in the graph.

11. Building and tests

cargo.build {"plugin": "notes"} is a native method of the host. A call still gets a process ID, a folder in /proc, and journal entries, like other calls. The method does these steps:

  1. It runs the tests of /plugins/notes/source/ under WASI.
  2. It builds the release .wasm for wasm32-wasip2.
  3. It returns {"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests": {"passed", "failed", "output"}, "warnings"}. If a step fails, it returns an error with the compiler or test output.
  • The dependency sausage-plugin = "0.1" points to the SDK copy at /plugins/cargo/sdk/.
  • Build scripts can compile C: CC_wasm32_wasip2 and AR_wasm32_wasip2 point to a WASI clang.
  • The warnings include unused imported methods. Remove them from imports/<plugin>.json, so that fewer changes of the provider break the plugin.

Tests:

  • sausage is the test runner (CARGO_TARGET_WASM32_WASIP2_RUNNER). It runs each test in its own instance, in parallel. A panic ends only its own test. Without this, the first panic would end the whole test run, because WASI has panic=abort.
  • The normal Rust test setup works: #[test], tests/*.rs, name filters, and --ignored. Async tests use #[sausage_plugin::test], like #[tokio::test]. #[should_panic] cannot work, because WASI cannot catch a panic.
  • Each test gets an empty temporary world as /, the package folder as its current folder, the variables that cargo sets, and its own /proc, /self, and /run.
  • The runner answers core.lock, core.unlock, core.notify, and core.log. A call to a plugin fails with a clear error: a test has no other plugins.

12. Boot

At start, the core makes each call in /config/boot.json, detached:

[{"method": "www.start", "params": {"port": 8080}}]

13. The SDK

Two crates: sausage-plugin, and sausage-plugin-macros, which sausage-plugin re-exports. The SDK also re-exports serde and schemars for the macros.

Item Purpose
#[rpc] Makes an impl an interface. #[rpc(endpoints(name = Type))] declares endpoint interfaces.
#[schema] For each type that a method takes or returns: adds the serde and schemars derives.
run(T) In main: serves the main interface on stdin and stdout.
call(method, params) An untyped call. Returns a JSON value.
.detach(), .on_notify(f) Options of a call.
Ctx An optional second argument of a method: ctx.notify(name, params).
lock(path) A lock guard. .try_now() does not wait. .keep() holds the lock until the process ends.
listen(path, value), connect(path) Endpoints.
background(future) Runs a task in this process. The process lives while a task runs.
import!("name") Generates mod name from imports/name.json.
state_dir(), write_atomic(path, bytes), pid(), endpoint_owner(path), exit_after_response() Helpers.
#[sausage_plugin::test] An async test. A plain #[test] also works.

Rules that the compiler enforces:

  • A method takes &self, an optional ctx: &Ctx, and at most one params argument.
  • The params argument is a #[schema] struct with named fields. Its fields are the params of the method.
  • Each method, each #[schema] type, and each field has a doc comment. Other agents read these docs to use the interface. Write them like good Rust documentation.

Method docs become summary (the first paragraph) and description in OpenRPC. Type and field docs become description in the JSON Schema. The generated imports contain them again as doc comments.

13.1 A plugin

[package]
name = "notes"
version = "0.1.0"
edition = "2024"

[dependencies]
sausage-plugin = "0.1"
serde_json = "1"
//! Notes: a list of short texts, stored in `/state/notes/notes.json`.
//!
//! Each call runs in its own process, so two calls can run at the same time.
//! `add` takes a lock on the file, so that no note gets lost.

use std::path::PathBuf;

use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};

/// The notes plugin. It keeps no state in memory: each call reads the file.
struct Notes;

/// The params of `add`.
#[schema]
struct AddParams {
    /// The text of the new note. It can contain newlines.
    text: String,
}

/// The result of `add`.
#[schema]
struct Added {
    /// The number of notes after the call, with the new note.
    count: usize,
}

#[rpc]
impl Notes {
    /// Add a note at the end of the list.
    ///
    /// Returns the number of notes after the call.
    async fn add(&self, AddParams { text }: AddParams) -> Result<Added> {
        let _lock = lock(file()).await?; // released on drop, or when the process ends
        let mut notes = load()?;
        notes.push(text);
        write_atomic(file(), serde_json::to_vec(&notes)?)?;
        Ok(Added { count: notes.len() })
    }

    /// List all notes, the oldest first.
    fn list(&self) -> Result<Vec<String>> {
        load() // no lock: write_atomic replaces the file in one step
    }
}

/// The file with the notes: a JSON list of strings.
fn file() -> PathBuf {
    state_dir().join("notes.json")
}

/// Read all notes. A missing file means no notes.
fn load() -> Result<Vec<String>> {
    match std::fs::read(file()) {
        Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
        Err(e) => Err(e.into()),
    }
}

fn main() {
    sausage_plugin::run(Notes);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[sausage_plugin::test]
    async fn add_appends_and_counts() {
        assert_eq!(Notes.add(AddParams { text: "milk".into() }).await.unwrap().count, 1);
        assert_eq!(Notes.add(AddParams { text: "eggs".into() }).await.unwrap().count, 2);
        assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]);
    }

    #[test]
    fn list_is_empty_without_a_file() {
        assert!(Notes.list().unwrap().is_empty());
    }
}

13.2 A server with an endpoint

//! A web server for the files in `/state/www/`.
//!
//! `start` starts the server in its own process and returns at once. Call it
//! detached, or the server ends with its caller. The running server serves
//! the endpoint `control` at `/proc/<pid>/control`.

use std::{cell::Cell, rc::Rc};

use sausage_plugin::{Result, background, listen, pid, rpc, schema};
use wstd::net::TcpListener;

/// The www plugin.
struct Www;

/// The params of `start`.
#[schema]
struct StartParams {
    /// The TCP port to listen on, on all interfaces.
    port: u16,
}

#[rpc(endpoints(control = Control))]
impl Www {
    /// Start the web server and return the ID of its process.
    ///
    /// The server runs until it gets `stop` on its endpoint, until
    /// `core.stop`, or until its caller ends. Fails if the port is in use.
    async fn start(&self, StartParams { port }: StartParams) -> Result<String> {
        let tcp = TcpListener::bind(&format!("0.0.0.0:{port}")).await?;
        let control = Rc::new(Control { hits: Cell::new(0) });
        listen("/self/control", control.clone()).await?;
        background(serve(tcp, control)); // the process lives while this task runs
        Ok(pid())
    }
}

/// The control endpoint of a running server.
struct Control {
    /// The number of HTTP requests that the server answered.
    hits: Cell<u64>,
}

#[rpc]
impl Control {
    /// The number of HTTP requests that the server answered since its start.
    fn stats(&self) -> Result<u64> {
        Ok(self.hits.get())
    }

    /// Stop the server. The process ends after this response.
    fn stop(&self) -> Result<()> {
        sausage_plugin::exit_after_response();
        Ok(())
    }
}

/// Accept HTTP connections, and answer each request from `/state/www/`.
async fn serve(tcp: TcpListener, control: Rc<Control>) {
    // …
}

fn main() {
    sausage_plugin::run(Www);
}

The same type can serve stdin and an endpoint. The example uses two types for a reason: www.stats through the core would start a fresh process, and that process would answer 0.

13.3 A singleton

/// Start the secrets service, or return the ID of the one that runs.
async fn start(&self) -> Result<String> {
    let Some(lock) = lock("/run/secrets").try_now().await? else {
        return sausage_plugin::endpoint_owner("/run/secrets"); // another instance runs
    };
    lock.keep(); // hold the lock until this process ends
    listen("/run/secrets", Secrets::open()?).await?;
    Ok(pid())
}

13.4 Imports

To use another plugin, an agent does these steps:

  1. Copy /proc/core/plugins/notes/discover.json to source/imports/notes.json.
  2. Read /proc/core/plugins/notes/client.rs: the exact code that the import generates.
  3. Write import!("notes") and the code that uses it.
  4. Call cargo.build, and remove the imported methods that the warnings call unused.
  5. Call core.activate.
sausage_plugin::import!("notes"); // imports/notes.json
sausage_plugin::import!("www"); // imports/www.json, with its endpoint "control"

/// Add a note, then start a web server and return its number of answered requests.
async fn example() -> Result<u64> {
    let added = notes::add(notes::AddParams { text: "milk".into() }).await?;
    eprintln!("{} notes", added.count); // stderr goes to the journal
    let pid = www::start(www::StartParams { port: 8080 }).detach().await?;
    let control = www::control::connect(&format!("/proc/{pid}/control")).await?;
    control.stats().await
}
  • The params of each method are one struct. The fields have the same names as the JSON params.
  • Nested types take their names from $defs. When the provider uses schemars, those are the names of its Rust types: AddParams stays AddParams.
  • The generated items are private to the crate, so rustc warns about each unused method.

14. The agent

  • agent.chat {"text": "..."} answers a message from a person. The result is the final text. The interim texts come as core.notify with the name text.
  • The agent takes a lock on /state/agent/history.jsonl. So a second message waits until the first turn has ended.
  • The tools: call (method, params, detach), read_file, write_file, list_dir, and copy_dir.
  • The configuration is in /config/agent.json.
  • The prompt stays short. It says where the contract is (/proc/core/PROTOCOL.md), where the SDK is (/plugins/cargo/sdk/), how to build and activate, and where the agent's own source is (/plugins/agent/source/).
  • A new version of the agent applies at the next chat. The current turn ends with the old code.

15. The console

  • rustyline gives line editing and history. Notifications appear while you type.
  • A short prompt. Results are indented. Agent notifications are dimmed.
Input Effect
text agent.chat
<plugin>.<method> {json} a call. The console prints the result.
ls [path], cat <path> the filesystem as a process sees it, with /proc and /run
methods <plugin> the methods from rpc.discover, with params and docs
ps the running processes
log [plugin or pid] [-n N] the journal
activate <plugin> <wasm>, revert <plugin> core.activate, core.revert
stop <pid>, kill <pid> core.stop, core.kill
help, quit

--headless stays: no console input, and the host runs until SIGTERM or core.exit.

16. Caches

  • The compiled code of each .wasm file is cached in $XDG_CACHE_HOME/sausage/, or ~/.cache/sausage/ without that variable. --cache-dir changes the folder.
  • The key is the SHA-256 of the file and Wasmtime's engine hash. So a Wasmtime upgrade never loads old machine code.
  • Wasmtime has a compile cache of its own. If it supports components, we use it. If not, our own cache needs about 20 lines.

17. What goes away

The broker, mailboxes, publish and subscribe, taps, watch and fs_changed, actor-served files, versions/, the JSON transcript tests, restarts and the rollback after a crash loop, /actor, /data, /public, the sausage-actor SDK, and the automatic install of a changed actor.wasm. There is no compatibility with old worlds.

18. Decisions

  • JSON-RPC, not component interfaces. Every language can read and write lines of JSON, and models know JSON-RPC from LSP and MCP.
  • A process per call. We get parallelism without threads. A new version applies at the next call, so the core needs no restarts and no rollback.
  • Attached by default. A process does not outlive its caller by accident.
  • Files to inspect, calls to act. This keeps the core API small, and the agent's file tools are enough to explore the world.
  • Generated imports and interface checks instead of versions. A model that translates a description by hand makes errors. A generator does not. The check catches the most common error in a world that changes itself: a change to one plugin breaks another one.
  • Docs are part of the interface. Other agents work from them, so the compiler requires them.
  • No permissions. The checks protect against mistakes, not against plugins.
  • Our own VFS. It works on Linux and macOS, and plugin-served files need it later.
  • TCP endpoints for now. They need no host code. VFS pipes can replace them later behind the same SDK API.

19. Later

  • Plugin-served files at /proc/<pid>/fs/, for example for an FTP plugin. A request gets a timeout of 10 seconds, because a process that reads its own served files would wait for itself forever.
  • VFS pipes for endpoints, instead of TCP ports.
  • Subscriptions to file changes.
  • Structured logging.
  • A secrets service.
  • Cancellation of calls.

20. Plan

The work happens on the branch plugins. Each step includes its tests.

First, three checks of risks:

  1. The round trip of typify, schemars 1.x, and jsoncompat on real documents. The result decides between jsoncompat and our own check.
  2. Virtual preopens in wasmtime-wasi: add /proc, /self, and /run to the preopen list, and send each descriptor under them to our backend.
  3. Whether Wasmtime's compile cache supports components.

Then the steps:

  1. PROTOCOL.md and DECISIONS.md from this proposal.
  2. The core: a process per call, JSON-RPC routing, the process tree, locks, the journal, and the compile cache.
  3. The VFS: /proc, /self, /run, and /proc/core/.
  4. The SDK and its macros.
  5. cargo.build, the test runner, activation, and the graph.
  6. The agent, the console, and boot.
  7. The removal of the old code.