Moonkale is extension-driven: the built-in editors are extensions. This guide takes you from an empty folder to a panel, a command, a language and a data source. Reference material: Manifest Reference, Contribution Points, Host API Reference, Publishing and Platforms.

Two halves — read which one you need

Part A is how to write an extension today, against moonkale-ext-api as tagged lib-v1 (Milestone 18 phase 6.3, 2026-10-03; what changed per tag: packages/ext-api/CHANGELOG.md). Part B (sections 1 onwards) is the target design — a moonkale.toml manifest, a Host handle, moonkale_shell::Tree panels, WIT components — kept so the implementation is held to it; none of Part B exists yet. The decision record for what was built instead is ADR-0013 JSON ABI before components.

Part A — writing an extension today

There are two kinds, and they can do different things:

static (Rust crate)wasm module (JSON ABI v1)
can contributepanels (any Dioxus Element), commands + keybindings, its own settings UI, flow-editor block librariescommands, optionally offered to the agent as tools
seesthe whole Workspace (sources, documents, settings, …)three host calls: list_sources, query, fetch_text, each checked against the granted permissions
shipscompiled into the binary; listed in moonkale_distribution::default_extensions() behind a Cargo featurea .wasm file in ~/.config/moonkale/extensions/ or <folder>/.moonkale/extensions/
runs onevery platformdesktop and server (wasmtime), browser (Worker); not on Android yet
examplepackages/editors/image (≈ 60 lines of extension code)packages/extensions/wordcount

A static extension

Depend on moonkale-ext-api (and moonkale-core for the model) and dioxus, implement the trait, and list the crate in packages/distribution — a Cargo feature and one #[cfg(feature = …)] line in default_extensions(); the shell is never edited.

  • Inside the repository: a crate under packages/editors/ or packages/extensions/, moonkale-ext-api = { workspace = true }.
  • In a repository of its own (as MathStruct/moonkale-julia does): depend by git tag, never a branch — moonkale-ext-api = { git = "https://github.com/MathStruct/Moonkale", tag = "lib-v1" } (likewise moonkale-core); Moonkale’s distribution then pulls your crate by git behind an off-by-default feature. A lib-vN tag marks a state where core, ext-api and graph-render are compatible.
use dioxus::prelude::*;
use moonkale_ext_api::prelude::*;
 
pub struct Hello;
 
impl Extension for Hello {
    fn manifest(&self) -> Manifest {
        // core(..) = always on; optional(..) = on, can be switched off; opt_in(..) = off until enabled
        Manifest::opt_in("dev.example.hello", "Hello", "Greets you from a side panel.")
            .with_permissions(&["read-sources"])
    }
 
    fn panels(&self, _ws: Workspace) -> Vec<PanelContribution> {
        // The contribution structs are #[non_exhaustive]: build them with
        // `new` and the builder methods, so a new field is not breaking.
        vec![PanelContribution::new("hello", "Hello", PanelHome::Side)
            .closable(true)
            .activity(Activity::new("puzzle", 100, "Hello"))]
    }
 
    fn render(&self, _panel_id: &str, ws: Workspace) -> Element {
        let n = ws.sources.open.read().len();
        rsx! { p { "Hello — {n} source(s) open." } }
    }
 
    fn commands(&self, _ws: Workspace) -> Vec<CommandContribution> {
        vec![CommandContribution::new("hello.greet", "Hello: Greet").key("Ctrl+Alt+H")]
    }
 
    fn run_command(&self, id: &str, mut ws: Workspace) {
        if id == "hello.greet" {
            ws.set_status("Hello!");
        }
    }
}

What the trait offers (packages/ext-api/src/extension.rs, every item documented — cargo doc -p moonkale-ext-api --open): manifest, panels (called on every shell render — read signals there to contribute one panel per open document), render, on_panel_closed, commands/run_command, claims (see below), flow_libraries (block libraries for the flow editor, see moonkale-julia’s Lux), settings (an Element shown under the extension’s row in the Extensions panel; write with Workspace::update_settings_in), locales (your strings per language, Fluent key = text files, English required — i18n::lookup, or t!(ws, L, "id"); see Languages and Themes), themes (theme files as JSON, {"name", "base", "tokens"}). Colours in your CSS are var(--mk-…) tokens only — tools/check-colors.py fails CI on literal colours.

More that an extension can rely on since Milestone 18:

  • Platform services by type. Define a type (pub struct GitRunner(pub fn(…) -> …)), let the app put a value in WorkspaceConfig::services, and find it with ws.service::<GitRunner>() — ext-api never has to know your service (git does it this way).
  • A server half. A server feature on your crate with #[post("/api/…")] server functions: the server binary registers them when your crate is linked with that feature (the distribution’s server feature turns it on). Use moonkale_server_host::jail_dir for every path a client names. Shared relays (terminal, LSP, LLM) stay in the host.
  • State that is not a file. Workspace::state_get/state_put (this machine’s store) and host_get/host_scan/host_write (the store of the folder’s host) with a moonkale_state::Record type — see the Agent’s saved sessions.
  • Properties on nodes and edges (Node::props, Edge::props), and content-addressed ids (NodeId::from_content) for sources whose entities are their structure (ADR-0015 The core model).
  • A folder’s settings are data, not authority: whatever your settings section writes to the workspace scope, a folder can never grant permissions, name a program or point a secret somewhere (Security). Offer such switches in the user scope only (see the Agent’s “Allow mutating tools”). Keep state in the Workspace or in signals the extension owns, not in the rendered element: a panel is remounted when it is docked elsewhere.

An editor is a static extension that contributes one panel per node it opens (see editors/image/src/extension.rs: it filters ws.docs.views for the nodes it can show) and claims those nodes: fn claims(&self, node: &Node) -> Option<u8> — the built-in code editors claim any text with 10, format editors their format with 50. When several enabled extensions claim a document, the shell keeps only the highest claimant’s tab (a tie goes to the user’s choice), so a new editor for, say, *.lenticulum.json takes those files from the code editor by claiming them above 10. A database driver contributes source openers instead (moonkale_core::SourceOpener: a name check and an open function), which the app adds to its Openers list.

A wasm extension

A core wasm module built with plain cargo build --target wasm32-unknown-unknown --release — no component tooling. It exports alloc(len) -> ptr, manifest() -> packed(ptr,len) and run(ptr,len) -> packed; it may import moonkale.log(ptr,len) and moonkale.call(ptr,len) -> packed. Every value is JSON:

// manifest()
{ "abi": 1, "id": "dev.example.count", "name": "Count", "description": "…",
  "permissions": ["read-sources"],
  "commands": [{ "id": "count.lines", "title": "Count: lines", "description": "…",
                 "input_schema": { "type": "object", "properties": { "source": {"type":"string"}, "node": {"type":"string"} } },
                 "llm_tool": true }] }
// run() receives { "command": "count.lines", "args": { … } } and returns { "ok": true, "result": "…" } or { "ok": false, "error": "…" }
// call() sends { "op": "list_sources" } | { "op": "query", "source": "…", "query": <a core Query as JSON> }
//             | { "op": "fetch_text", "source": "…", "node": "…" }
//   and gets back { "ok": true, "result": <JSON> } or { "ok": false, "error": "…" }

The types are in packages/ext-host/src/abi.rs (a Rust guest can depend on moonkale-ext-host with no features to share them); packages/extensions/wordcount/src/lib.rs is a complete example and build.sh installs it. The user grants the permissions in the Extensions panel; until then the host refuses the calls.


Part B — the target design (not built)

1. What an extension is

An extension = a manifest (moonkale.toml, declarative) + code (Rust, optionally compiled to a WASM component). The manifest is read first and drives the UI before your code runs; code is activated lazily.

flowchart LR
  M[moonkale.toml] -->|registered at startup| SHELL[shell builds menus, palette, keybindings]
  SHELL -->|activation event| CODE[your crate: activate, command, panel]
  CODE -->|Host handle| APP[graph, commands, ui, storage, llm, net]

Two kinds:

kindruns ascan renderplatformswhen to choose
staticRust crate linked into the binaryfull Dioxus Elementallfirst-party editors, anything needing wgpu or raw Dioxus
wasmWASM component loaded at runtimedeclarative moonkale_shell::Treedesktop, server, web (Worker)everything distributable

Both implement the same Extension trait against the same Host.

2. Create the crate

my-extension/
├─ Cargo.toml
├─ moonkale.toml
└─ src/lib.rs

Cargo.toml:

[package]
name = "my-extension"
version = "0.1.0"
edition = "2021"
 
[lib]
crate-type = ["cdylib", "rlib"]   # cdylib for wasm, rlib for static
 
[dependencies]
moonkale-ext-api = "0.1"
serde = { version = "1", features = ["derive"] }

moonkale.toml (see Manifest Reference):

[extension]
id      = "dev.example.hello"
name    = "Hello"
version = "0.1.0"
api     = "^0.1"
kind    = "wasm"
 
[activation]
on = ["command:hello.*", "view:hello.panel"]
 
[[contributes.command]]
id = "hello.greet"
title = "Hello: Greet"
args = { name = "string" }
 
[[contributes.panel]]
id = "hello.panel"
title = "Hello"
home = "side"

3. Implement Extension

use moonkale_ext_api::prelude::*;
 
#[derive(Default)]
struct Hello { greetings: u32 }
 
impl Extension for Hello {
    fn activate(&mut self, host: Host) -> Result<(), ExtError> {
        host.set_status("Hello activated");
        Ok(())
    }
 
    fn command(&mut self, id: &str, args: Value) -> Result<Value, ExtError> {
        match id {
            "hello.greet" => {
                self.greetings += 1;
                let name = args["name"].as_str().unwrap_or("world");
                Ok(json!({ "message": format!("Hello, {name}!") }))
            }
            _ => Err(ExtError::UnknownCommand),
        }
    }
 
    fn panel(&mut self, _id: &str, ctx: PanelCtx) -> PanelOutput {
        ui::column([
            ui::text(format!("Greeted {} times", self.greetings)),
            ui::button("Greet", Action::command("hello.greet", json!({"name": "you"}))),
        ]).into()
    }
}
 
moonkale_ext_api::export!(Hello);   // static: inventory registration; wasm: component exports

That is a complete extension. Build:

  • static: add the crate to the desktop/web binary’s dependencies (first-party only);
  • wasm: cargo component build --release → target/wasm32-wasip2/release/my_extension.wasm.

Install (desktop): copy the folder to ~/.config/moonkale/extensions/dev.example.hello/. See Publishing and Platforms.

4. Talk to the graph

The Host gives you the same surface every editor uses (Graph-Native Model):

// find markdown pages linking to the current node
let view = host.query(Query::neighbours(ctx.node, Direction::In).kind(EdgeKind::Links)).await?;
for node in view.nodes() { host.notify(format!("{} links here", node.label)); }
 
// read content
let text = host.fetch(ctx.node).await?.as_text()?;
 
// write (requires permissions.sources = ["write"])
host.apply(Transaction::new().update_props(ctx.node, [("reviewed", Value::Bool(true))])).await?;
 
// react to changes
let mut events = host.subscribe(Filter::kind(NodeKind::Page));
while let Some(ev) = events.next().await { /* … */ }

Queries fan out across all open sources you’re permitted to read. You never see connection strings, file handles or sockets.

5. Contribute things

Each [[contributes.*]] table in the manifest is a contribution point:

you want to add…contributecode needed?
a dockable panelpanelpanel()
a command (palette, menus, keybindings, LLM tool)commandcommand()
an editor for a node kindeditorpanel() keyed by node
a language (grammar, highlight, LSP)languageno — data only
a database / data sourcesourceSourceFactory impl (native)
node/edge visuals in the graph viewrendererno (declarative) / yes (custom)
a keybinding, a themekeybinding, themeno
an LLM toolcommand with llm_tool = truecommand()

Worked examples: Example - Hello Panel, Example - Data Source, Example - Language.

6. Permissions

Declare what you need; users grant on first use; every Host call is checked.

[permissions]
sources = ["read", "write"]
network = ["https://api.example.com/*"]
process = false
llm     = true

A denied call returns ExtError::Denied(Capability) — handle it, don’t unwrap. See Host API Reference.

7. State and panels

Panel content is remounted on structural moves (docking into another group). Keep state in your extension struct or in host.kv_* storage, not in the panel tree. Static extensions can use Dioxus signals; the rule is the same: state lives outside the panel.

8. Platforms

Declare platforms = ["desktop", "web"] if you depend on a capability that isn’t everywhere (process spawning, native drivers). Check the Platform Matrix. A source contribution using a native driver is desktop/server-only automatically; the shell offers it to web users through the server.

9. Testing

  • moonkale-ext-api ships a TestHost (in-memory sources, recorded calls) so command() and panel() can be unit-tested with plain cargo test.
  • moonkale dev --extension ./my-extension (planned) runs the desktop app with your extension hot-reloaded on rebuild.

10. Versioning

api = "^0.1" is checked against moonkale-ext-api’s version. Breaking API changes bump the major and get a migration note in Problem Log. Contribution points are part of the API; contribution instances are not.

Checklist before publishing

  • manifest validates (moonkale ext check)
  • permissions are minimal
  • commands have title, args schema and, if agent-safe, llm_tool = true with a risk
  • panels keep state outside the tree
  • platforms is honest
  • README with screenshots