Plugin System

July 24, 2026 · View on GitHub

This document describes Thoth's plugin architecture — how it works, how plugins are discovered and loaded, and how to implement your own plugin.


Table of Contents


Why a Plugin System

Features like API/network support, database connectivity, and additional file formats (CSV, YAML, XML) were initially planned as core additions. Instead, these are implemented as plugins for the following reasons:

  • Core stays fast and minimal — users only pay for what they install
  • Independent release cycles — plugins ship on their own schedule
  • Third-party extensibility — anyone can write a plugin without touching core
  • Clean separation of concerns — each plugin owns its domain fully

Architecture Overview

┌─────────────────────────────────────────────────┐
│                  ThothApp                        │
│                                                  │
│  ┌──────────────┐     ┌──────────────────────┐  │
│  │ PluginManager│────▶│  PluginRegistry       │  │
│  │              │     │  (in-memory, runtime) │  │
│  └──────┬───────┘     └──────────────────────┘  │
│         │                                        │
│         │ loads & sandboxes                      │
│         ▼                                        │
│  ┌──────────────────────────────────────────┐   │
│  │           Wasmtime Runtime               │   │
│  │                                          │   │
│  │  ┌────────────┐  ┌────────────────────┐  │   │
│  │  │ csv-loader │  │ postgres-source     │  │   │
│  │  │ plugin.wasm│  │ plugin.wasm        │  │   │
│  │  └────────────┘  └────────────────────┘  │   │
│  └──────────────────────────────────────────┘   │
│                                                  │
│  ┌───────────────┐  ┌──────────────────────────┐ │
│  │ WasmFileLoader│  │  WasmFileViewerLoader    │ │
│  │ (file-loader  │  │  (file-loader +          │ │
│  │  world)       │  │   file-viewer world)     │ │
│  └───────────────┘  └──────────────────────────┘ │
└─────────────────────────────────────────────────┘

The PluginManager is initialized once at app startup, scans all plugin directories, loads each .wasm file into its own sandboxed Wasmtime instance, and registers it against the capabilities it declares in plugin.toml.

When a file is opened the host checks whether the matching plugin also implements file-viewer. If it does, a WasmFileViewerLoader is used and the viewer renders either a native table (when the plugin returns preferred-display: table) or a custom RenderNode tree (when it returns custom).


Technology Choice: WebAssembly

Plugins are compiled to WebAssembly (WASM) and run inside a Wasmtime sandbox. The interface between Thoth and plugins is defined using WIT (WebAssembly Interface Types).

Comparison with alternatives

ConcernWASM + WasmtimeDynamic libs (.so/.dylib)Subprocess (JSON-RPC)
Safety / sandboxSandboxedFull process accessIsolated process
Cross-platformOne binaryPer-platform buildAny language
Language agnosticAny WASM targetRust only (stable ABI)Any language
PerformanceNear-nativeNativeIPC overhead
ABI stabilityStable via WITBreaks across Rust ver.JSON is stable
DistributionSingle .wasmComplex packagingSingle binary

WASM wins on portability, safety, and distribution simplicity. A plugin author compiles once and the .wasm file runs on macOS, Windows, and Linux without modification.


Plugin Types

TypeCapability keyWhat it does
File Loaderfile-loaderTeaches Thoth to open a new file format (CSV, YAML, Parquet, etc.)
File Viewerfile-viewerControls how records are rendered — table mode or custom RenderNode tree
Data Sourcedata-sourceConnects to an external source — REST API, database, message queue
UI Componentnew-ui-componentRenders a fully interactive panel — owns its own state machine
ExporterexporterAdds new export formats or destinations
Search Providersearch-providerExtends the search experience with custom indexing or remote results
Data Producerdata-producerOffers a dataset to the data bus (#113); appears as a source in a consumer's picker. Pairs with the data-producer export

A single plugin can declare multiple capabilities. file-viewer always pairs with file-loader — use the file-viewer-plugin world for this combination. data-source always pairs with ui-component — use the data-source-plugin world.


Plugin Storage and Discovery

Thoth scans three locations at startup:

ScopePathPurpose
BundledNext to the Thoth binary / inside the app bundleFirst-party plugins shipped with Thoth
User~/.config/thoth/plugins/ (Linux/macOS) %APPDATA%\thoth\plugins\ (Windows)Personal plugins installed by the user
Debug<workspace>/assets/plugins/Dev-only: found by cargo run without a full install

Discovery algorithm:

  1. Walk each directory in order.
  2. For each subdirectory that contains plugin.toml + plugin.wasm, attempt to load it.
  3. Read plugin.toml to extract id, name, version, and capabilities.
  4. Validate that the WASM component exports thoth:plugin/plugin-meta.
  5. Register the plugin under each capability it declares.
  6. Skip and log a warning for any plugin that fails validation or sandbox initialization.

Plugins installed via the Settings → Plugins UI land in the user directory. Bundled plugins cannot be uninstalled but can be disabled via the toggle in Settings → Plugins.


Plugin Structure

A plugin is a directory containing:

~/.config/thoth/plugins/
└── csv-loader/
    ├── plugin.toml   ← required metadata
    ├── plugin.wasm   ← compiled WASM component
    └── icon.png      ← optional 64×64 icon shown in Settings

plugin.toml format

id          = "com.example.csv-loader"   # Reverse-domain unique identifier
name        = "CSV Loader"
version     = "0.2.1"
description = "Load CSV and TSV files as tabular JSON records"
author      = "Your Name <you@example.com>"
homepage    = "https://github.com/example/csv-loader"   # optional

capabilities = ["file-loader", "file-viewer"]

# One [[file-loader]] block per distinct MIME type / extension group
[[file-loader]]
file-type            = "text/csv"
supported-extensions = ["csv"]

[[file-loader]]
file-type            = "text/tab-separated-values"
supported-extensions = ["tsv"]

WIT Interface Definitions

The full interface is defined in wit/thoth-plugin.wit at the repository root. This is the single source of truth — all language toolchains generate their bindings from this file.

Shared types

interface types {
    enum capability {
        file-loader,
        file-viewer,
        data-source,
        exporter,
        search-provider,
        new-ui-component,
        data-producer,
    }

    record plugin-info {
        id:           string,
        name:         string,
        version:      string,
        description:  string,
        capabilities: list<capability>,
        author:       option<string>,
        homepage:     option<string>,
        icon:         option<string>,   // Phosphor icon Unicode glyph
    }

    record plugin-error {
        code:    u32,
        message: string,
    }

    record setting-data {
        key:   string,
        value: string,
    }
}

plugin-meta — required by every plugin

interface plugin-meta {
    use types.{plugin-info};

    get-info: func() -> plugin-info;
}

plugin-lifecycle — required by every plugin

interface plugin-lifecycle {
    /// Called after the plugin is fully loaded and registered.
    /// `setting` is a JSON array of {key, value} objects with the plugin's
    /// persisted settings, e.g. [{"key":"url","value":"https://..."}].
    /// Parse it to restore saved state.
    on-load: func(setting: string);

    /// Called before the plugin is unloaded. Release held resources.
    on-close: func();

    /// Called automatically by the host whenever the user saves settings while
    /// this plugin's pane is active. `setting` is the same JSON array format
    /// as on-load. This covers both plugin-specific settings changes and any
    /// other settings save while the plugin is open.
    on-setting-change: func(setting: string);
}

plugin-settings — required by every plugin

interface plugin-settings {
    use types.{plugin-error};

    record settings-output {
        node-json:   string,   // JSON-encoded UiNode tree
        height-hint: u32,
    }

    /// Render the settings UI. Called when the user opens Settings → Plugins
    /// for this plugin. Return a UiNode tree using the same DSL as ui-component.
    /// Return a plain text node if the plugin has no configurable settings.
    render-settings: func() -> result<settings-output, plugin-error>;
}

http-client — host-provided import for data-source plugins

Data-source plugins get outbound HTTP access via this host-provided import. All requests pass through the host's network-policy layer (domain allowlist, SSRF guard) before being forwarded.

interface http-client {
    use types.{plugin-error};

    record http-request {
        url:     string,
        method:  string,                        // "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
        headers: list<tuple<string, string>>,
        body:    option<list<u8>>,
    }

    record http-response {
        status:  u16,
        headers: list<tuple<string, string>>,
        body:    list<u8>,
    }

    /// Synchronous fetch. Blocks until the response arrives.
    /// Use for programmatic paths (schema discovery, initial data load) where
    /// showing a spinner is not required.
    fetch: func(req: http-request) -> result<http-response, plugin-error>;

    /// Asynchronous submit. Returns a request-id string immediately without
    /// blocking. The host dispatches the request on a background thread and
    /// delivers the result by calling handle-event with:
    ///   widget-id = <request-id>
    ///   kind      = "http-response"
    ///   value     = JSON: {"ok":{"status":200,"headers":[["k","v"]],"body":"...","duration_ms":42}}
    ///            or JSON: {"err":{"code":"error","message":"..."}}
    ///            or JSON: {"err":{"code":"consent_pending","message":"domain '...' not approved"}}
    ///
    /// The "consent_pending" code means the user has not yet approved the
    /// domain. A consent popup is shown; once approved, the host re-dispatches
    /// the request and delivers a new http-response event. While waiting, keep
    /// loading=true and show a "Waiting for consent approval…" spinner.
    ///
    /// Use submit() when you want to show a spinner while waiting. Store the
    /// request-id, set a loading flag in your state, and return a UI tree with
    /// a {"type":"spinner"} node. When handle-event fires for that request-id,
    /// clear the loading flag and render the result.
    submit: func(req: http-request) -> string;
}

file-loader — capability: file-loader

interface file-loader {
    use types.{plugin-error};

    supported-extensions: func() -> list<string>;
    open:      func(path: string)          -> result<u64, plugin-error>;
    get:       func(idx: u64)              -> result<string, plugin-error>;
    get-range: func(start: u64, count: u64) -> result<list<string>, plugin-error>;
    raw-bytes: func(idx: u64)              -> result<list<u8>, plugin-error>;
}

open indexes the file and returns the total record count. get returns a single record as a JSON object string. get-range returns records [start, start + count) as JSON strings in one bulk, sequential read — used by consumers that cross many records at once (the dataset bus, export); implement it as a single pass rather than looping get, which is O(n²) for stream-parsed formats. raw-bytes returns a record as raw UTF-8 bytes (used by copy-to-clipboard and exporters).

file-viewer — capability: file-viewer

interface file-viewer {
    use types.{plugin-error};

    enum display-mode {
        table,    // host renders a native table; render-record() never called
        custom,   // host calls render-record() for every visible row
    }

    preferred-display:  func() -> display-mode;
    column-headers:     func() -> option<list<string>>;
    render-record:      func(record-json: string) -> result<render-output, plugin-error>;

    record render-output {
        node-json:   string,   // JSON-encoded RenderNode tree
        height-hint: u32,
    }
}

data-source — capability: data-source

interface data-source {
    use types.{plugin-error};

    record config-entry   { name: string, description: string, required: bool, value: string }
    record field-schema   { name: string, type-hint: string, nullable: bool }
    record source-schema  { name: string, fields: list<field-schema> }
    record pane-output    { node-json: string, height-hint: u32 }

    required-config: func() -> list<config-entry>;
    connect:         func(config: list<config-entry>) -> result<string, plugin-error>;
    schema:          func(handle: string)             -> result<list<source-schema>, plugin-error>;
    query:           func(handle: string, q: string)  -> result<string, plugin-error>;
    close:           func(handle: string);
    render-pane:     func(handle: string)             -> result<pane-output, plugin-error>;
}

ui-component — capability: new-ui-component

interface ui-component {
    use types.{plugin-error};

    record ui-event {
        widget-id: string,
        /// "click"         — button / icon-button pressed (value is empty)
        /// "change"        — any input value changed (JSON-encoded new value)
        /// "http-response" — async HTTP result from submit() (JSON payload)
        /// "notify"        — host notification, e.g. "consent-approved"
        kind:      string,
        value:     string,
    }

    record ui-output {
        node-json:   string,
        height-hint: u32,
    }

    render-ui:      func()               -> result<ui-output, plugin-error>;
    handle-event:   func(event: ui-event) -> result<ui-output, plugin-error>;
    /// Optional sidebar panel. Return some(output) to show a sidebar slot.
    /// Return none if this plugin has no sidebar content.
    /// Called after every handle-event to keep the sidebar in sync.
    render-sidebar: func()               -> result<option<ui-output>, plugin-error>;
}

Worlds — pick the one that matches your plugin

world base-plugin {           // every plugin satisfies this
    export plugin-meta;
    export plugin-lifecycle;
    export plugin-settings;
}

world file-loader-plugin {    // file format support only
    include base-plugin;
    export file-loader;
}

world file-viewer-plugin {    // file format + custom rendering
    include base-plugin;
    export file-loader;
    export file-viewer;
}

world ui-component-plugin {   // standalone interactive panel
    include base-plugin;
    export ui-component;
}

world data-source-plugin {    // external data source + interactive UI
    include base-plugin;
    import http-client;        // host provides outbound HTTP
    export data-source;
    export ui-component;
}

world exporter-plugin {
    include base-plugin;
    export exporter;
}

How the Runtime Works

Startup sequence

ThothApp::new()
    └── PluginManager::init()
            ├── scan bundled_plugins_dir
            ├── scan user_plugins_dir
            └── (debug) scan assets/plugins/
                    └── for each plugin directory:
                            ├── read plugin.toml  → Plugin struct
                            ├── set plugin.bundled = true/false
                            ├── compile .wasm with wasmtime Engine
                            ├── validate: component exports thoth:plugin/plugin-meta
                            ├── link WASI host functions
                            ├── instantiate (validates all imports satisfied)
                            └── register in PluginRegistry

Per-call flow (file-loader example)

FileViewer::open(path)
    └── PluginManager::plugin_has_capability("csv", FileViewer) → true
    └── PluginManager::open_file_with_viewer("csv", path)
            └── WasmFileViewerLoader::open(engine, wasm_path, path)
                    ├── WASI: preopened dir = file's parent (read-only)
                    ├── set fuel = u64::MAX / 2
                    └── wasmtime call: file-loader.open(path) → record_count

Per frame (virtual scrolling — only visible rows):
    PluginTableViewer::render()
        ├── loader.column_headers()    → ["Name", "Age", ...]  [called once, cached]
        ├── loader.preferred_display() → Table                  [called once, cached]
        └── for each visible row idx:
                loader.get(idx)        → serde_json::Value      [cached in LRU]
                (Table mode: host renders cells natively)

Data-source plugin open flow

PluginManager::open_data_source(plugin_id)
    └── WasmDataSourceLoader::open(engine, wasm_path, policy, plugin_id, settings)
            ├── instantiate component
            ├── if settings non-empty: call on-load(settings_json)
            │       └── plugin parses JSON, initialises internal state
            └── return WasmDataSourceLoader

ThothApp::ui() — called every frame:
    └── poll_plugin_http_results(ctx)
            ├── drain_http_results() from background threads
            ├── for each (request_id, outcome):
            │       build UiEvent { widget_id: request_id, kind: "http-response", value: json }
            │       loader.handle_event(event) → new UiOutput
            └── if has_pending_http(): ctx.request_repaint()

Settings-change notification flow

ThothApp — user clicks "Save" in Settings dialog
    ├── settings saved to disk
    ├── self.settings updated in memory
    ├── PluginManager::update_plugin_settings(new_plugin_settings)
    │       └── overwrites internal RwLock<HashMap> with latest per-plugin values
    └── if active_plugin_pane exists:
            └── WasmDataSourceLoader::on_setting_change(updated_settings)
                    └── wasmtime call: plugin-lifecycle.on-setting-change(settings_json)
                            └── plugin re-parses JSON, updates internal state

If plugins.enabled changed, a toast notification is shown and no further plugin calls are made — the change takes effect only after the app is restarted.

Fuel replenishment

Each WIT call replenishes fuel to 5,000,000,000 units (PLUGIN_FUEL_BUDGET) before calling into the plugin via the refuel() helper in wasm_data_source.rs. This gives plugins ample budget for serialising large JSON responses while still bounding runaway infinite loops. Fuel exhaustion surfaces as ThothError::Unknown with a "all fuel consumed" message.


Implementing a File Loader Plugin

This walkthrough creates a CSV plugin in Rust using cargo-component.

1. Install tooling

cargo install cargo-component
rustup target add wasm32-wasip1

2. Scaffold the plugin

cargo component new --lib csv-loader
cd csv-loader

3. Configure Cargo.toml

[package]
name    = "csv-loader"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen-rt = "0.41"
serde_json     = "1"

[package.metadata.component]
package = "com.example:csv-loader"

[package.metadata.component.target]
path  = "../../wit"
world = "file-loader-plugin"

4. Implement the plugin (src/lib.rs)

mod bindings;

use std::cell::RefCell;
use bindings::exports::thoth::plugin::{
    file_loader::Guest as FileLoaderGuest,
    plugin_lifecycle::Guest as LifecycleGuest,
    plugin_meta::Guest as MetaGuest,
    plugin_settings::{Guest as SettingsGuest, SettingsOutput},
};
use bindings::thoth::plugin::types::{Capability, PluginError};

struct CsvPlugin;

struct State {
    headers: Vec<String>,
    records: Vec<Vec<String>>,
}

thread_local! {
    static STATE: RefCell<Option<State>> = const { RefCell::new(None) };
}

impl MetaGuest for CsvPlugin {
    fn get_info() -> bindings::exports::thoth::plugin::plugin_meta::PluginInfo {
        bindings::exports::thoth::plugin::plugin_meta::PluginInfo {
            id:           "com.example.csv-loader".to_string(),
            name:         "CSV Loader".to_string(),
            version:      "0.1.0".to_string(),
            description:  "Load CSV files as JSON records".to_string(),
            capabilities: vec![Capability::FileLoader],
            author:       Some("Your Name <you@example.com>".to_string()),
            homepage:     None,
            icon:         None,
        }
    }
}

impl LifecycleGuest for CsvPlugin {
    fn on_load(_setting: String) {}   // no saved settings for this plugin
    fn on_close() {
        STATE.with(|s| *s.borrow_mut() = None);
    }
    fn on_setting_change(_setting: String) {}
}

impl SettingsGuest for CsvPlugin {
    fn render_settings() -> Result<SettingsOutput, PluginError> {
        Ok(SettingsOutput {
            node_json: r#"{"type":"text","value":"No configurable settings.","muted":true}"#.into(),
            height_hint: 0,
        })
    }
}

impl FileLoaderGuest for CsvPlugin {
    fn supported_extensions() -> Vec<String> {
        vec!["csv".to_string(), "tsv".to_string()]
    }

    fn open(path: String) -> Result<u64, PluginError> {
        // ... parse and index the file
        Ok(record_count)
    }

    fn get(idx: u64) -> Result<String, PluginError> {
        // ... return record at idx as JSON object string
    }

    fn get_range(start: u64, count: u64) -> Result<Vec<String>, PluginError> {
        // ... return records [start, start + count) in one sequential pass
    }

    fn raw_bytes(idx: u64) -> Result<Vec<u8>, PluginError> {
        // ... return raw bytes for clipboard/export
    }
}

bindings::export!(CsvPlugin with_types_in bindings);

5. Build and install

cargo component build --release
mkdir -p ~/.config/thoth/plugins/csv-loader
cp target/wasm32-wasip1/release/csv_loader.wasm ~/.config/thoth/plugins/csv-loader/plugin.wasm
cp plugin.toml ~/.config/thoth/plugins/csv-loader/plugin.toml

Restart Thoth — opening a .csv file will now use your plugin.


Implementing a File Viewer Plugin

A file viewer plugin controls how records are displayed in the viewer panel. It always pairs with file-loader — use the file-viewer-plugin world.

Change the world in Cargo.toml:

[package.metadata.component.target]
path  = "../../wit"
world = "file-viewer-plugin"

Table mode (simplest — host renders the table natively):

impl FileViewerGuest for MyPlugin {
    fn preferred_display() -> DisplayMode { DisplayMode::Table }

    fn column_headers() -> Option<Vec<String>> {
        STATE.with(|s| s.borrow().as_ref().map(|st| st.headers.clone()))
    }

    fn render_record(_record_json: String) -> Result<RenderOutput, PluginError> {
        Err(PluginError { code: 0, message: "not used in table mode".into() })
    }
}

Custom mode (full control via RenderNode tree):

impl FileViewerGuest for MyPlugin {
    fn preferred_display() -> DisplayMode { DisplayMode::Custom }

    fn column_headers() -> Option<Vec<String>> { None }

    fn render_record(record_json: String) -> Result<RenderOutput, PluginError> {
        let map: serde_json::Map<String, serde_json::Value> =
            serde_json::from_str(&record_json).map_err(|e| PluginError {
                code: 1, message: e.to_string(),
            })?;

        let children: Vec<serde_json::Value> = map.iter().map(|(_k, v)| {
            let text = v.as_str().unwrap_or("").to_string();
            if text.contains("ERROR") {
                serde_json::json!({"type":"colored","color":"#ff6b6b",
                                   "child":{"type":"text","value":text}})
            } else {
                serde_json::json!({"type":"text","value":text})
            }
        }).collect();

        let node_json = serde_json::json!({"type":"row","children":children}).to_string();
        Ok(RenderOutput { node_json, height_hint: 0 })
    }
}

Update plugin.toml:

capabilities = ["file-loader", "file-viewer"]

Implementing a Data Source Plugin

A data source plugin uses the data-source-plugin world which combines data-source + ui-component + the http-client host import.

The plugin renders its own connection/query UI via render-ui / handle-event, and exposes data via connect / query / close.

Settings initialisation via on-load

The host calls on-load(setting) after instantiation, passing the plugin's persisted settings as a JSON array. Parse it to restore saved state:

fn on_load(setting: String) {
    if setting.is_empty() { return; }
    if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&setting) {
        STATE.with(|s| {
            let mut st = s.borrow().clone();
            for entry in entries {
                let key   = entry["key"].as_str().unwrap_or("");
                let value = entry["value"].as_str().unwrap_or("").to_string();
                match key {
                    "url"    => st.url    = value,
                    "method" => st.method = value,
                    _        => {}
                }
            }
            *s.borrow_mut() = st;
        });
    }
}

Minimal data-source skeleton

impl DataSourceGuest for MyPlugin {
    fn required_config() -> Vec<ConfigEntry> {
        vec![ConfigEntry {
            name: "url".into(), description: "Base URL".into(),
            required: true,     value: "".into(),
        }]
    }

    fn connect(config: Vec<ConfigEntry>) -> Result<String, PluginError> {
        let url = config.iter().find(|e| e.name == "url")
            .ok_or(PluginError { code: 1, message: "missing url".into() })?;
        STATE.with(|s| s.borrow_mut().base_url = url.value.clone());
        Ok("handle-1".to_string())
    }

    fn schema(_handle: String) -> Result<Vec<SourceSchema>, PluginError> {
        Ok(vec![]) // return table/endpoint list
    }

    fn query(_handle: String, _q: String) -> Result<String, PluginError> {
        Ok("[]".to_string())
    }

    fn close(_handle: String) {}

    fn render_pane(_handle: String) -> Result<PaneOutput, PluginError> {
        // return a RenderNode tree for the main pane
        Ok(PaneOutput { node_json: r#"{"type":"text","value":"No data"}"#.into(), height_hint: 0 })
    }
}

Async HTTP — Submitting Requests Without Blocking

http_client::fetch() is synchronous and blocks the render loop — the UI freezes while waiting for the response. For requests where you want to show a spinner, use http_client::submit() instead.

How it works

  1. Plugin calls submit(req) → gets a request_id string back immediately
  2. Host spawns a background thread to execute the request
  3. Plugin stores request_id, sets loading = true, returns a UI tree with a {"type":"spinner"} node
  4. Host polls each frame; when the response arrives it calls handle_event with kind = "http-response" and widget_id = request_id
  5. Plugin matches the request_id, clears loading, stores the response, returns updated UI

Plugin-side pattern

// In handle_event, "click" on the send button:
"send" => {
    let req = build_request(&st);
    let request_id = http_client::submit(&req);
    st.pending_request_id = Some(request_id);
    st.loading = true;
    st.response = None;
}

// Route http-response events before normal widget dispatch:
fn handle_event(event: UiEvent) -> Result<UiOutput, PluginError> {
    if event.kind == "http-response" {
        handle_http_response(&mut st, &event);
        return Ok(ui_out(build_ui(&st)));
    }
    // ... normal event dispatch
}

// Build UI with spinner while loading:
fn build_ui(st: &State) -> Value {
    if st.loading {
        return json!({
            "type": "column", "gap": 12,
            "children": [
                {"type": "spinner"},
                {"type": "text", "value": "Sending request…", "muted": true}
            ]
        });
    }
    // ... normal UI
}

// Handle the response event:
fn handle_http_response(st: &mut State, event: &UiEvent) {
    if st.pending_request_id.as_deref() != Some(&event.widget_id) { return; }
    st.loading = false;
    st.pending_request_id = None;

    let parsed: serde_json::Value = serde_json::from_str(&event.value).unwrap_or_default();
    if let Some(ok) = parsed.get("ok") {
        let body = ok["body"].as_str().unwrap_or("").to_string();
        st.response = Some(Ok(body));
    } else if let Some(err) = parsed.get("err") {
        let msg = err["message"].as_str().unwrap_or("unknown error").to_string();
        st.response = Some(Err(msg));
    }
}

The host automatically calls ctx.request_repaint() while any submit() request is in flight, so the spinner animates without any extra work.


Plugins describe their UI as a tree of nodes serialized to JSON (the node-json string returned by render-ui / render-record / render-settings / render-pane / handle-event). Rather than hand-writing that JSON, depend on the thoth-plugin-sdk crate and build the tree with type-safe builders. The host renders the same RenderNode types, so the SDK is a single source of truth for both sides — plugin authors get autocomplete, compile-time checking, and no field-name typos.

Add the dependency

The SDK is not yet published to crates.io, so depend on it via git. Pin a tag (or rev) for reproducible builds:

[dependencies]
# Plugins only *describe* UI; they never render, so no egui feature.
thoth-plugin-sdk = { git = "https://github.com/anitnilay20/thoth", tag = "v0.3.25", features = ["plugin"] }

A few things that "just work" with this git form:

  • Monorepo resolution — the SDK is a workspace member of the main app repo; Cargo finds it by package name. (Add package = "thoth-plugin-sdk" if a future workspace ever defines another crate by a colliding name.)
  • The derive macrothoth-plugin-sdk depends on thoth-plugin-sdk-macros by an in-repo path. Inside a git source that path resolves against the checked out repo, so #[derive(PluginMeta)] works with no extra setup.

Once the SDK is published, this becomes thoth-plugin-sdk = { version = "0.1", features = ["plugin"] }.

The bundled plugins under plugins/ live in the same repo, so they use a relative path dependency instead:

thoth-plugin-sdk = { path = "../../thoth-plugin-sdk", features = ["plugin"] }

Cargo features:

  • default — the DSL component types + bon builders (all a wasm plugin needs).
  • plugin — adds the ToNodeJson wire-protocol trait (node.to_json()) and the PluginMeta derive.
  • egui — the host-only renderer; do not enable it in plugins.

One glob brings in everything a plugin author needs (components + builders, RenderNode, PluginState, SettingsMap, and — with the plugin feature — PluginMeta and ToNodeJson):

use thoth_plugin_sdk::prelude::*;

Build a tree and serialize it

use thoth_plugin_sdk::components::{Button, ButtonColor, Column, Input, Typography};
use thoth_plugin_sdk::render_node::RenderNode;

fn build_ui(state: &State) -> RenderNode {
    RenderNode::Column(
        Column::builder()
            .gap(8.0)
            .children(vec![
                RenderNode::Text(Typography::builder().text("Endpoint").build()),
                RenderNode::Input(
                    Input::builder().id("url").value(state.url.clone()).grow(true).build(),
                ),
                RenderNode::Button(
                    Button::builder().id("send").label("Send").color(ButtonColor::Primary).build(),
                ),
            ])
            .build(),
    )
}

// In render-ui / handle-event:
fn ui_out(node: RenderNode) -> UiOutput {
    UiOutput { node_json: serde_json::to_string(&node).unwrap_or_default(), height_hint: 0 }
}

RenderNode is an internally-tagged enum ({"type":"button", ...}), so a node serializes to the same JSON the host deserializes. Containers (Row, Column, Split, Scroll, Tabs, Modal, …) hold children: Vec<RenderNode>; leaf widgets wrap a component struct (RenderNode::Button(Button)).

Interaction & events

Interactive widgets carry an id. When the user interacts, the host renders the tree, collects events, and calls your handle-event with a ui-event (widget-id, kind, value):

Widgetkindvalue
Button, IconButton, list item, DataRow body, Modal closeclickempty (list item: index)
Input, Select, Checkbox, Slider, NumberInput, Radio, Toggle, CodeEditorchangethe new value
MultiSelect, KeyValueListchangeJSON (array / entries)
ButtonGroups, Tabschangeselected index / header label
DataRow carettoggleempty
list action buttonactionJSON {"item":i,"action":j}

Your handle-event matches on widget_id, mutates state, and re-renders the full tree (the standard immediate-mode flow):

fn handle_event(event: UiEvent) -> Result<UiOutput, PluginError> {
    match event.widget_id.as_str() {
        "url"  => state.url = event.value.clone(),
        "send" => { /* submit */ }
        _ => {}
    }
    Ok(ui_out(build_ui(&state)))
}

Worked examples

The bundled plugins are built entirely with the SDK and are the best reference:

  • plugins/csv-loader — minimal (render-settings only).
  • plugins/url-source — a full request/response UI: rows with layout props, tabs with actions, modals (open/close-id/width-pct), inputs, key-value lists, a code editor, badges, and a JSON-tree response view.
  • plugins/seshat — a Postgres browser: a tabbed sidebar, a schema tree of DataRows, a SQL CodeEditor, a results table, and connection modals.

The component builders and a live gallery of every widget are in the thoth-plugin-sdk crate (cargo run -p thoth-plugin-sdk --example gallery --features egui).

Plugin state (thoth_plugin_sdk::state)

WIT exports are free functions with no self, so plugins keep their runtime state in a global. Instead of hand-rolling thread_local! { static STATE: RefCell<Option<T>> } and repeating the borrow dance, use PluginState<T> — a lazily-initialised cell usable directly as a static:

use thoth_plugin_sdk::state::PluginState;

#[derive(Default)]
struct State { url: String }

static STATE: PluginState<State> = PluginState::new();

// read (auto-inits from Default) / mutate:
let url = STATE.with(|s| s.url.clone());
STATE.with_mut(|s| s.url = new_url);

// when "absent" is meaningful (e.g. a file that hasn't been opened):
STATE.set(State { url });          // store explicitly
STATE.try_with(|s| s.url.clone()); // -> Option, None if unset
STATE.reset();                     // drop it (e.g. from on-close)

with/with_mut/get require T: Default; set/reset/try_with/ is_initialised do not. Don't call with/with_mut re-entrantly (they borrow internally, like RefCell). All three bundled plugins use this.

Settings (thoth_plugin_sdk::settings)

The host passes settings to on-load / on-setting-change as a JSON array of {key, value} records. SettingsMap reads and builds that payload:

use thoth_plugin_sdk::settings::SettingsMap;

fn on_load(setting: String) {
    let s = SettingsMap::from_json(&setting);
    let url    = s.get("url").unwrap_or_default();
    let method = s.get_or("method", "GET");
    // ...
}

// build a payload back out
let json = SettingsMap::new().with("url", &url).with("method", &method).to_json();

Plugin metadata (#[derive(PluginMeta)])

Every plugin must implement plugin-meta's get_info() — pure boilerplate. The PluginMeta derive generates it from a declarative attribute:

use thoth_plugin_sdk::PluginMeta;

#[derive(PluginMeta)]
#[plugin(
    id          = "com.example.my-plugin",
    name        = "My Plugin",
    version     = env!("CARGO_PKG_VERSION"),
    description = "Does useful things",
    capabilities = [DataSource, NewUiComponent],
    author      = "Me",        // optional
    icon        = "\u{E28C}",  // optional (homepage also optional)
)]
struct MyPlugin;

id/name/version/description are required; author/homepage/icon are optional. Values are any ToString expression (string literal, env!(...), or a glyph const). capabilities lists the binding Capability variant names (FileLoader, FileViewer, DataSource, Exporter, SearchProvider, NewUiComponent). The derive expands to the get_info() impl against your cargo component bindings, so it expects the conventional top-level mod bindings;. (Lifecycle hooks — on-load/on-close/on-setting-change — are still written by hand; only the static metadata is derived.)

UiNode DSL Reference

The tables below document the on-the-wire JSON shape. With the SDK (recommended, see above) you build these via component builders rather than writing JSON by hand — the field names map 1:1 to the builder methods.

Used by ui-component (via render-ui / handle-event) and plugin-settings (via render-settings). Every node is a JSON object with a mandatory "type" field.

Layout

NodeFieldsNotes
rowchildren, gap?: number, align?: "start|center|end|fill", padding?: number, max-width?: boolHorizontal
columnchildren, gap?: number, padding?: number, bg-color?: stringVertical
splitchildren (exactly 2), gap?: number, separator?: boolEqual-width columns; separator: true draws a 1px divider
grouplabel: string, childrenLabelled section box
scrollchild, height?: numberScrollable area
footerchildren, gap?: number, padding?: numberSticky bottom panel (rendered before content)
modalid, title, open: bool, children, close-id?: string, width-pct?: number, height-pct?: numberOverlay dialog with backdrop; close-id emits a click event when × is pressed
tabsid, header: string[], childrenTabbed container; one child per header entry
spacerheight?: numberEmpty vertical space
separatorHorizontal rule

Display (no interaction)

NodeFields
textvalue: string, size?: "sm|md|lg", muted?: bool
headingvalue: string, level?: 1–4
badgelabel: string, color?: "#rrggbb"
codevalue: string, language?: string
markdownvalue: string
progressvalue: number (0.0–1.0)
spinnersize?: number
json-treevalue: any

Inputs (all fire "change" event with JSON-encoded new value)

All inputs take id: string, label: string, disabled?: bool.

NodeExtra fieldsEvent value
text-inputvalue, placeholder, required, grow?: bool, multiline?: bool, rows?: numberJSON string
number-inputvalue, min, maxJSON number
password-inputvalueJSON string
textareavalue, rowsJSON string
selectvalue, options: [{value,label}]JSON string
multi-selectvalue: string[], optionsJSON array
checkboxchecked: boolJSON bool
togglechecked: boolJSON bool
radiovalue, optionsJSON string
slidervalue, min, maxJSON number
key-value-listentries: [{key,value}], add-labelJSON array of {key,value}
button-groupid, options: [{value,label}], value: stringJSON string (selected value)

Actions (fire "click" event with empty value)

NodeFieldsNotes
buttonid, props: {label, button-type, color, enabled}, copy?: stringcopy writes text to clipboard on the host side without a plugin round-trip
icon-buttonid, icon: string, tooltip, enabled?: bool, frame?: boolUses Phosphor icon glyphs

List items

list nodes support a badge field on each item for coloured pill labels (e.g. HTTP method badges):

{
  "type": "list",
  "id": "saved-requests",
  "items": [
    {
      "title": "My Request",
      "description": "https://api.example.com",
      "badge": { "text": "GET", "color": "blue" },
      "actions": [{ "icon": "x", "tooltip": "Delete" }]
    }
  ]
}

Badge colour values: "blue", "green", "red", "orange", "purple", "gray".

Note: The actions array is parsed by the host but action buttons are not yet rendered. Declaring them now is forward-compatible — they will become hover-revealed icon buttons in a future release.


RenderNode Schema Reference

Used by file-viewer (render-record) and data-source (render-pane). WIT does not support recursive types, so the entire tree is a JSON string.

Node typeJSON fieldsNotes
textvalue: stringPlain label
boldchild: RenderNodeBold child
italicchild: RenderNodeItalic child
coloredcolor: "#rrggbb", child: RenderNodeHex-colored child
badgelabel: string, color: "#rrggbb"Filled pill badge
linklabel: string, url: stringClickable hyperlink
rowchildren: RenderNode[]Horizontal layout
columnchildren: RenderNode[], gap?: numberVertical layout
key-valuekey: string, value: RenderNodekey: value pair
collapsiblelabel: string, children: RenderNode[]Collapsing section
tableheaders: string[], rows: RenderNode[][]Inline table with header row
json-treevalue: anyInteractive JSON tree
spinnerAnimated loading indicator
spacerheight?: numberEmpty vertical space
headingvalue: string, level?: 1–4Section heading

Example

{
  "type": "column",
  "children": [
    { "type": "heading", "value": "Response", "level": 3 },
    {
      "type": "row",
      "children": [
        { "type": "text", "value": "Status:" },
        { "type": "badge", "label": "200 OK", "color": "#10b981" }
      ]
    },
    { "type": "json-tree", "value": { "id": 1, "name": "Alice" } }
  ]
}

Implementing a Theme Plugin

Theme plugins change Thoth's colour scheme without WASM. A theme is a directory containing plugin.toml + theme.json.

plugin.toml

family groups related variants (shown as a section header in the theme picker). catalog is a list of [variant-name, is-dark] pairs — one entry per variant in theme.json.

id           = "com.example.gruvbox"
name         = "Gruvbox"
version      = "1.0.0"
description  = "Gruvbox colour schemes"
capabilities = ["theme"]

[theme]
family  = "Gruvbox"
catalog = [
  ["Gruvbox Dark",  true],
  ["Gruvbox Light", false],
]

theme.json — colour token map

The file is a JSON object keyed by the variant names listed in catalog. Each variant must include all tokens below.

{
  "Gruvbox Dark": {
    "name":               "Gruvbox Dark",
    "dark_mode":          true,
    "bg":                 "#282828",
    "bg_panel":           "#1d2021",
    "bg_sunken":          "#191b1c",
    "surface":            "#3c3836",
    "surface_raised":     "#504945",
    "surface_active":     "#665c54",
    "fg":                 "#ebdbb2",
    "fg_muted":           "#928374",
    "syntax_key":         "#83a598",
    "syntax_string":      "#b8bb26",
    "syntax_number":      "#d79921",
    "syntax_bool":        "#8ec07c",
    "syntax_punctuation": "#ebdbb2",
    "success":            "#98971a",
    "warning":            "#d65d0e",
    "error":              "#cc241d",
    "info":               "#689d6a",
    "accent":             "#d79921",
    "accent_secondary":   "#458588",
    "sidebar_hover":      "#3c383680",
    "sidebar_header":     "#ebdbb2",
    "indent_guide":       "#504945"
  },
  "Gruvbox Light": {
    "name":               "Gruvbox Light",
    "dark_mode":          false,
    "bg":                 "#fbf1c7",
    "bg_panel":           "#f2e5bc",
    "bg_sunken":          "#ebdbb2",
    "surface":            "#d5c4a1",
    "surface_raised":     "#bdae93",
    "surface_active":     "#a89984",
    "fg":                 "#3c3836",
    "fg_muted":           "#7c6f64",
    "syntax_key":         "#076678",
    "syntax_string":      "#79740e",
    "syntax_number":      "#b57614",
    "syntax_bool":        "#427b58",
    "syntax_punctuation": "#3c3836",
    "success":            "#79740e",
    "warning":            "#b57614",
    "error":              "#9d0006",
    "info":               "#427b58",
    "accent":             "#b57614",
    "accent_secondary":   "#076678",
    "sidebar_hover":      "#d5c4a133",
    "sidebar_header":     "#3c3836",
    "indent_guide":       "#d5c4a1"
  }
}

All tokens map 1-to-1 to fields in ThemeColors (src/theme.rs). Missing tokens fall back to the built-in defaults.

A dynamic WASM variant can expose a theme-provider interface that returns the colour map at runtime — useful for adaptive themes or palettes generated from user preferences. See #70 for the full spec.


Security Model

Each plugin runs in a fully isolated Wasmtime instance:

ProtectionMechanism
Memory isolationEach plugin has its own WASM linear memory; cannot read host or other plugin memory
Filesystem accessWASI preopened-dir grants read access to the file's parent directory only (set at open time)
No direct network accessPlugins cannot open sockets directly; all HTTP goes through the host's http-client import
Network policyPer-plugin allowlist (intersection of plugin-declared and user-approved domains), SSRF guard (all DNS answers checked, IPv4 + IPv6 private ranges), and rate limiter (minimum of plugin and user RPM caps)
User consentFirst request to an unlisted domain triggers a consent popup; the plugin receives {"err":{"code":"consent_pending"}} until the user approves; the host re-dispatches the request automatically on approval
CPU budgetFuel is replenished to 5,000,000,000 units before every WIT call — infinite loops are caught and surfaced as a recoverable error
Bundled vs userBundled plugins (shipped with Thoth) set plugin.bundled = true at scan time; the UI prevents uninstalling them
Download integrityThe sha256 field in the marketplace manifest is always verified against the downloaded archive; installation is aborted if the checksum is absent or does not match

Integration with Core

The bridge between plugins and core lives in src/plugin/:

src/plugin/
├── mod.rs                      ← Plugin, FileLoaderMeta, Capability types; pub mod re-exports
├── manager.rs                  ← PluginManager: discovery, loading, scan_directory
├── plugin_registry.rs          ← PluginRegistry: capability_index + plugin_key maps
├── wasm_loader.rs              ← WasmFileLoader        (file-loader-plugin world)
├── wasm_file_viewer_loader.rs  ← WasmFileViewerLoader  (file-viewer-plugin world)
├── wasm_data_source.rs         ← WasmDataSourceLoader  (data-source-plugin world)
│                                    async HTTP: background thread + mpsc channel polling
├── network_policy.rs           ← Per-plugin domain allowlist + SSRF guard
├── wasm_plugin_settings.rs     ← Settings rendering + persistence bridge
└── render_node.rs              ← RenderNode enum + render_node() egui walker

FileType in src/file/loaders/mod.rs has a variant for each loader kind:

pub enum FileType {
    Ndjson(NdjsonFile),
    JsonArray(JsonArrayFile),
    Single(SingleValueFile),
    Plugin(WasmFileLoader),
    PluginWithViewer(WasmFileViewerLoader),
}

WasmDataSourceLoader in src/plugin/wasm_data_source.rs holds the complete state for an active data-source pane:

pub struct WasmDataSourceLoader {
    inner: Mutex<WasmDataSourceInner>,         // store + bindings
    consent_rx: Receiver<ConsentRequest>,       // domain consent requests
    http_rx: Receiver<(String, HttpCallResult)>,// async HTTP results
    pending_count: Arc<AtomicUsize>,            // in-flight request counter
    plugin_id: String,
}

ThothApp::poll_plugin_http_results() is called at the top of every frame to drain completed HTTP results and feed them back into the plugin as handle_event calls.


Roadmap: Database Plugins via WASI Sockets

See also: DATABASE_PLUGINS.md for the full design exploration. The recommended path is a host-provided tcp-client import on the current wasip1 target (reusing network_policy.rs) plus sync drivers driven from a per-instance owner thread — which supersedes the WASI-sockets plan below.

The current http-client WIT import covers REST/HTTP data sources. For native database protocols (PostgreSQL wire protocol, MySQL, Redis RESP, etc.) the plan is to expose WASI socket access to data-source-plugin components.

Wasmtime supports wasi:sockets/tcp in WASI Preview 2. When Thoth migrates to wasm32-wasip2, database plugins will be able to open their own TCP connections and implement the full wire protocol in pure Rust compiled to WASM — no host changes needed for new databases.

Target database strategy

DatabaseApproachStatus
PostgreSQLpostgres crate (sync, pure Rust) over WASI TCPFeasible today
MySQL / MariaDBmysql crate (sync, pure Rust) over WASI TCPFeasible today
RedisRESP protocol is trivial sync over WASI TCPFeasible today
ElasticsearchREST API → existing http-client WITWorks today
ClickHouseHTTP interface → existing http-client WITWorks today
MongoDBSync OP_MSG + BSON over WASI TCP, or Atlas Data API via HTTPFeasible
Cassandracdrs (sync) over WASI TCPFeasible
KafkaKafka binary protocol over WASI TCPSignificant work
SQLitePure-Rust SQLite engine (limbo) compiled to WASMTracking limbo maturity
OracleOCI is a proprietary C library — no pure-Rust driver existsNative dylib exception

The key principle: drivers live in the plugin, not the host. Adding support for a new database requires writing a new plugin, never changing Thoth core. Users download only the plugins they need.