Skip to content
arcshipsPublic

About

A plugin runtime for software that iterates on itself — the substrate where agents write, swap and hot-reload their own plugins in Rust, TypeScript, Python and Go, across processes and machines.

Topics

Resources

Contributing

Security policy

Stars

139 stars

Watchers

0 watching

Forks

Repository files navigation

rutis

A plugin runtime for software that iterates on itself

Agents and long-running programs write, swap and hot-reload their own plugins, and rutis keeps every change safe.
A Rust core · plugins in TypeScript, Python and Go · across processes and machines

crates.io npm PyPI docs.rs CI License: MIT

Quick start · Guides · Design philosophy · API docs · 中文


When software is itself an agent, or is built by agents, it cannot wait for someone else to wrap the world for it. To reach a new API, model, tool or device, it writes a plugin, tries it, changes it and replaces it, while it keeps running. The connection then becomes part of the program: with a lifecycle, visible, and cleaned up when it goes.

Doing that over and over is only safe on a runtime that guarantees a few things: nothing starts before what it depends on is there; everything a plugin registered is released exactly once when it stops; a failed load rolls back; and when an implementation is replaced, only what uses it restarts. rutis is that runtime. Plugins declare the services they need and provide, and rutis decides when they start, stop and start again.

The lifecycle model comes from Cordis. rutis implements it once, in a small Rust core, and carries it to other languages, other processes and other machines.

The loop

What rutis gives you
Make a connection rutis-host new --lang node|bun|python|go creates a plugin project. Plugins run in the host's process, in a language runtime, or on another machine, and call each other's services by name.
Understand it rutis-host check lists every plugin's dependencies and what it provides, and fails on a service nothing provides. In Rust, ctx.diagnostics() shows each plugin's state, dependencies and bindings.
Iterate on it rutis-host dev reloads a plugin when its files change. Configuration updates live. Replace a provider, even with one in another language, and the plugins using it follow.
Without breaking Gated start, LIFO cleanup that runs exactly once, rollback on failure. Synchronous calls that would deadlock across runtimes are refused. Loading, unloading and replacing again and again leaves nothing behind.

Quick start

Without Rust

npx @arcships/rutis-host new weather --lang node
cd weather && npm install
npx rutis-host dev          # run the plugin, reload it when files change
npx rutis-host check        # what each plugin needs and provides

The same works for other languages: bunx --bun @arcships/rutis-host new weather --lang bun, uvx rutis-host new weather --lang python (then uv sync, uv run rutis-host dev), or rutis-host new weather --lang go (then go mod tidy, rutis-host dev).

import { definePlugin } from '@arcships/rutis'

interface Llm {
  ask(question: string): Promise<string>
}

export default definePlugin<{ city?: string }>({
  inject: ['llm'],                             // services it needs: starts once all are there
  provides: { weather: { today: 'async' } },   // services it offers, and how each method is called
  apply(ctx, config) {
    const llm = ctx.use<Llm>('llm')
    const city = config.city ?? 'Oslo'
    ctx.provide('weather', {
      today: () => llm.ask(`weather in ${city}`),
    })
  },
})

llm can come from another plugin in the same process, from a Python plugin, or from another machine; this plugin stays the same. The full workflow is in the guides for TypeScript, Bun, Python and Go.

In Rust

cargo add rutis
cargo add tokio --features full
use std::sync::Arc;
use rutis::{BoxFuture, CordisError, Ctx, Effect, Plugin, Typed, TypedPlugin};

/// A service is a type.
struct Greeting(String);

/// Provides Greeting. What apply registers is released when the plugin stops.
struct Greeter(&'static str);

impl Plugin for Greeter {
    fn name(&self) -> &str { "greeter" }

    fn apply<'a>(&'a self, ctx: &'a Ctx) -> BoxFuture<'a, Result<Effect, CordisError>> {
        Box::pin(async move {
            ctx.provide(Greeting(format!("hello from {}", self.0)))?;
            Ok(Effect::Done)
        })
    }
}

/// Depends on Greeting: starts when it appears, restarts when it is replaced.
struct Listener;

impl TypedPlugin for Listener {
    type Deps = (Arc<Greeting>,);

    fn name(&self) -> &str { "listener" }

    fn apply<'a>(&'a self, _: &'a Ctx, (greeting,): Self::Deps) -> BoxFuture<'a, Result<Effect, CordisError>> {
        Box::pin(async move {
            println!("{}", greeting.0);
            Ok(Effect::Done)
        })
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ctx = Ctx::root()?;
    let listener = ctx.plugin(Typed::new(Listener));  // waits for a Greeting

    let english = ctx.plugin(Greeter("English"));
    (&english).await?;
    (&listener).await?;                               // hello from English

    english.dispose().await?;                         // the listener stops…
    let esperanto = ctx.plugin(Greeter("Esperanto"));
    (&esperanto).await?;
    (&listener).await?;                               // …and starts again: hello from Esperanto

    ctx.shutdown().await?;
    Ok(())
}

Nothing touched Listener: when the provider changed, it stopped and started again on its own. Run it from the repository with cargo run -p rutis --example quickstart. To run TypeScript, Python and Go plugins inside your Rust application, see embedding in Rust.

From experiment to production

A plugin depends on a service's name, never on its implementation, its language or where it runs. So a capability can start as a quick experiment and harden over time, while everything that uses it stays the same:

  1. Try: write it in Python or TypeScript, perhaps with an agent, and let rutis-host dev reload it on every change.
  2. Prove: run it in a real host, using and used by other plugins; the service's name and methods settle here.
  3. Solidify: move it to another machine, or rewrite the parts that have stood the test of time in Rust.
flowchart LR
    subgraph s1["Try"]
        p1["planner"] -->|calendar| c1["Python plugin"]
    end
    subgraph s2["Move to the internal network"]
        p2["planner"] -->|calendar| c2["Plugin on an internal node"]
    end
    subgraph s3["Solidify"]
        p3["planner"] -->|calendar| c3["Rust implementation"]
    end
    s1 ~~~ s2 ~~~ s3
Loading

Across all three stages, not one line of planner changes and the host does not restart.

How it works

rutis is a small application operating system. The core is the kernel; everything it connects to is a plugin under the same lifecycle:

Operating system rutis
Kernel: small, stable, rarely changed The rutis core, depending only on tokio, tokio-util and thiserror
Processes Plugins; language runtimes, remote nodes and dylibs are plugins too
System calls Services, provided and used by name or by type
Drivers Language runtimes, node links, Cordis mounts
Scheduling and reclamation Start once dependencies are ready, stop when they go away, clean up in reverse order
flowchart LR
    subgraph host["Host (Rust)"]
        core["rutis core<br/>lifecycle · dependencies · cleanup"]
        rust["Rust / dylib plugins"]
    end
    js["Node / Bun runtime<br/>npm ecosystem, Cordis plugins"]
    py["Python runtime<br/>ML and data ecosystem"]
    go["Go plugin binaries"]
    peer["Nodes on other machines<br/>their processes and devices"]
    core --- rust
    core <--> js
    core <--> py
    core <--> go
    core <-->|WebSocket + TLS| peer
Loading

The lifecycle model lives only in the core, and every guarantee is pinned by tests; the other languages get a small SDK instead of a copy of the framework. Each plugin runs in a fiber whose state follows its dependencies:

stateDiagram-v2
    direction LR
    [*] --> Pending
    Pending --> Loading : dependencies ready
    Loading --> Active : apply succeeds
    Loading --> Failed : apply fails, rolled back
    Active --> Unloading : dependency gone / config update / dispose
    Failed --> Unloading : dependency back / config update / restart
    Unloading --> Pending : cleanup done
    Unloading --> Disposed : terminated
    Disposed --> [*]
Loading

Services are registered by type in Rust and by name across languages. The event bus dispatches in four ways: emit, parallel, serial and waterfall. rutis-loader describes the plugins to run as layered configuration and keeps reconciling the running system toward it. Hosts link over WebSocket and TLS to share services, run plugins on another machine, forward events and reconnect after a drop.

Trade-offs

  • Processes and latency: each language needs at least one process, and a synchronous cross-language call takes about 30 µs (measured on the Node side). Hot paths belong in Rust or in one runtime.
  • Weaker semantics across a boundary: events become notifications and waterfalls are not forwarded; what cannot cross a boundary is a written rule, not a silent degradation.
  • Shared failure: plugins in one runtime share its process. Isolation means more runtime instances.

The design philosophy explains these choices, and how rutis differs from Cordis.

Packages

For Rust (crates.io) JavaScript (npm) Python (PyPI)
The core rutis
Writing plugins rutis-sdk (dylib plugins) @arcships/rutis rutis
Running plugins in your app rutis-loader, rutis-bridge, rutis-dylib (dylib plugins) @arcships/rutis-runtime (Node), @arcships/rutis-bun (Bun) rutis
A host without Rust rutis-host @arcships/rutis-host rutis-host

Go plugins use the module github.com/arcships/rutis/go/rutis: the SDK, and the runtime each plugin binary carries.

All of these form a release train: released together at one version, currently 0.10. Use the same version for every rutis package.

Built with rutis

Project
rutis-agent · rutis-cli A minimal coding agent in which the model service, tools, streaming driver and TUI are all plugins. Try it offline with cargo run -p rutis-cli -- --scripted.
rutis-host A host without Rust: runs TypeScript, JavaScript, Python and Go plugins from a rutis.json, reloads them during development, links machines.
rutis-dsh Runs the full dsh web interface inside a rutis host, with model calls served by aimux in the same process.
aimux-llm Wraps aimux as an LLM service plugin.

Documentation

Platforms and status

rutis is at 0.x and its API is still evolving. Breaking changes are listed in the release notes and come with a migration guide.

  • The core is pure Rust with tokio, tokio-util and thiserror as its only dependencies; it needs Rust 1.88 or later.
  • Language runtimes and rutis-host (Node, Bun, Python and Go rows, remote runtimes, shared services, peers, Cordis mounts) run on Linux, macOS and Windows x64 (MSVC); they need Node 22+, Bun 1.4+ or Python 3.10+; Go plugins are compiled binaries, built with Go 1.24+. The Bun runtime is not tested on Windows yet.
  • dylib plugins load on Linux, macOS and Windows x64 (MSVC).

Contributing

Issues and pull requests are welcome: bugs, places where the docs are unclear, features you would like. Please read the contributing guide first, and report security issues privately as described in the security policy.

Acknowledgements and license

The design of rutis comes from Cordis by Shigma. Without Cordis's thinking about plugins, contexts and dependencies, this project would not exist.

Released under the MIT license.

About

A plugin runtime for software that iterates on itself — the substrate where agents write, swap and hot-reload their own plugins in Rust, TypeScript, Python and Go, across processes and machines.

Topics

Resources

Contributing

Security policy

Stars

139 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages