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-apias taggedlib-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 — amoonkale.tomlmanifest, aHosthandle,moonkale_shell::Treepanels, 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 contribute | panels (any Dioxus Element), commands + keybindings, its own settings UI, flow-editor block libraries | commands, optionally offered to the agent as tools |
| sees | the whole Workspace (sources, documents, settings, …) | three host calls: list_sources, query, fetch_text, each checked against the granted permissions |
| ships | compiled into the binary; listed in moonkale_distribution::default_extensions() behind a Cargo feature | a .wasm file in ~/.config/moonkale/extensions/ or <folder>/.moonkale/extensions/ |
| runs on | every platform | desktop and server (wasmtime), browser (Worker); not on Android yet |
| example | packages/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/orpackages/extensions/,moonkale-ext-api = { workspace = true }. - In a repository of its own (as
MathStruct/moonkale-juliadoes): depend by git tag, never a branch —moonkale-ext-api = { git = "https://github.com/MathStruct/Moonkale", tag = "lib-v1" }(likewisemoonkale-core); Moonkale’s distribution then pulls your crate by git behind an off-by-default feature. Alib-vNtag marks a state wherecore,ext-apiandgraph-renderare 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 inWorkspaceConfig::services, and find it withws.service::<GitRunner>()—ext-apinever has to know your service (git does it this way). - A server half. A
serverfeature on your crate with#[post("/api/…")]server functions: the server binary registers them when your crate is linked with that feature (the distribution’sserverfeature turns it on). Usemoonkale_server_host::jail_dirfor 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) andhost_get/host_scan/host_write(the store of the folder’s host) with amoonkale_state::Recordtype — 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
Workspaceor 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:
| kind | runs as | can render | platforms | when to choose |
|---|---|---|---|---|
static | Rust crate linked into the binary | full Dioxus Element | all | first-party editors, anything needing wgpu or raw Dioxus |
wasm | WASM component loaded at runtime | declarative moonkale_shell::Tree | desktop, 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.rsCargo.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 exportsThat is a complete extension. Build:
- static: add the crate to the
desktop/webbinary’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… | contribute | code needed? |
|---|---|---|
| a dockable panel | panel | panel() |
| a command (palette, menus, keybindings, LLM tool) | command | command() |
| an editor for a node kind | editor | panel() keyed by node |
| a language (grammar, highlight, LSP) | language | no — data only |
| a database / data source | source | SourceFactory impl (native) |
| node/edge visuals in the graph view | renderer | no (declarative) / yes (custom) |
| a keybinding, a theme | keybinding, theme | no |
| an LLM tool | command with llm_tool = true | command() |
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 = trueA 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-apiships aTestHost(in-memory sources, recorded calls) socommand()andpanel()can be unit-tested with plaincargo 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,argsschema and, if agent-safe,llm_tool = truewith arisk - panels keep state outside the tree
-
platformsis honest - README with screenshots