Moly
Moly is a Makepad app for interacting with local and remote LLMs. You can learn more about it on GitHub.
Moly Kit is a Rust crate with Makepad widgets for building AI applications. It is built on top of aitk, which provides framework-agnostic core types, API clients, and state management for working with AI models.
Moly Kit takes aitk's foundation and wraps it in ready-to-use Makepad widgets --
most notably a batteries-included Chat widget.
The following chapters are dedicated to Moly Kit, the crate. They assume you have
read the aitk documentation and are familiar with
its core concepts (BotClient, ChatController, Message, MessageContent, plugins,
etc.). These tutorials focus on how to use aitk with Moly Kit's Makepad widgets.
Quickstart
This guide gets you from zero to a working chat in a Makepad app.
Prerequisites
This guide assumes you are familiar with Makepad and have a bare-bones app ready. You should also have read the aitk documentation, as Moly Kit builds on its types and patterns.
Installation
Add Moly Kit to your Cargo.toml:
[dependencies]
makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "main" }
moly-kit = { git = "https://github.com/moly-ai/moly-ai.git", features = ["full"] }
Pin to a specific version with tag = "x.x.x" or rev = "<commit>" instead
of branch = "main" if you want to stay on a stable version.
Register widgets
Define your App and register Moly Kit's widgets in script_mod:
app_main!(App);
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
makepad_widgets::script_mod(vm);
moly_kit::widgets::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
Place the Chat widget
Use the Chat widget in your DSL layout:
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
mod.widgets.MyChatBase = #(MyChat::register_widget(vm))
load_all_resources() do #(App::script_component(vm)) {
ui: Root {
main_window := Window {
window +: { inner_size: vec2(800 600) }
pass +: { clear_color: #xfff }
body +: {
my_chat := mod.widgets.MyChatBase {
width: Fill
height: Fill
flow: Down
padding: 12
chat := Chat {}
}
}
}
}
}
}
Configure the Chat widget
The Chat widget needs a ChatController from aitk to function. Set it up in
on_after_new, which runs once when the widget is created:
use std::sync::{Arc, Mutex};
use makepad_widgets::*;
use moly_kit::prelude::*;
#[derive(Script, Widget)]
struct MyChat {
#[deref]
view: View,
#[rust]
controller: Option<Arc<Mutex<ChatController>>>,
}
impl Widget for MyChat {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.view.draw_walk(cx, scope, walk)
}
}
impl ScriptHook for MyChat {
fn on_after_new(&mut self, vm: &mut ScriptVm) {
let mut client = OpenAiClient::new("https://api.openai.com/v1".into());
client.set_key("your-api-key").unwrap();
let controller = ChatController::builder()
.with_client(client)
.with_basic_spawner()
.build_arc();
controller
.lock()
.unwrap()
.dispatch_mutation(ChatStateMutation::SetBotId(
Some(BotId::new("gpt-4.1-nano")),
));
self.controller = Some(controller.clone());
vm.with_cx_mut(|cx| {
self.chat(cx, ids!(chat))
.write()
.set_chat_controller(cx, Some(controller));
});
}
}
That's it. After this setup, the Chat widget handles user input, streaming
responses, and message rendering automatically.
What's happening
- Create a client:
OpenAiClientconnects to any OpenAI-compatible endpoint (OpenAI, Ollama, OpenRouter, etc.). - Build the controller:
ChatController::builder()creates the state manager that coordinates the conversation.with_basic_spawner()provides cross-platform async task execution. - Set the model:
SetBotIdis a state mutation that directly sets a specific model on the controller. - Give it to the widget:
set_chat_controllergives the controller toChatso it can drive the conversation.
Moly Kit doesn't duplicate methods from Chat into Makepad's autogenerated
ChatRef but provides read() and write() helpers to access the inner widget.
Next steps
This minimal setup uses a single client with a hardcoded model. The next chapter, Multiple Providers and Dynamic Models, shows how to support multiple providers, dynamically load available models, and use plugins to automate model selection.
Multiple Providers and Dynamic Models
The Quickstart used a single client with a hardcoded model. This chapter builds on that to show how to support multiple providers, discover models at runtime, and use plugins for automation.
The patterns shown here match what the moly-mini example does.
RouterClient
aitk's RouterClient aggregates multiple BotClient implementations into one,
routing requests based on a prefix in the BotId. See the
aitk Router Client documentation
for full details.
Instead of creating a single OpenAiClient, build a RouterClient with multiple
sub-clients:
fn build_client() -> RouterClient {
let client = RouterClient::new();
// Ollama runs locally, no key needed.
let ollama = OpenAiClient::new("http://localhost:11434/v1".into());
client.insert_client("ollama", Box::new(ollama));
// Add OpenAI if a key is available.
if let Some(key) = option_env!("OPEN_AI_KEY") {
let mut openai = OpenAiClient::new("https://api.openai.com/v1".into());
let _ = openai.set_key(key);
client.insert_client("open_ai", Box::new(openai));
}
// Add more providers following the same pattern.
client
}
Loading models dynamically
Instead of hardcoding a BotId, ask the controller to fetch available models from
all configured providers:
let controller = ChatController::builder()
.with_client(build_client())
.with_basic_spawner()
.build_arc();
controller.lock().unwrap().dispatch_task(ChatTask::Load);
ChatTask::Load triggers an async call to bots() on the client. When it completes,
the controller's state().bots is populated with all available models (prefixed by
their router key, e.g. ollama/llama3, open_ai/gpt-4.1).
The Chat widget includes a built-in ModelSelector that lets the user pick from
the loaded models. Once the user selects a model, the widget sets the BotId on the
controller automatically.
Plugins
aitk's ChatControllerPlugin trait lets you hook into state changes and task
execution. Plugins form a composable pipeline -- they observe mutations, react to
state transitions, and can intercept tasks before they execute. See the
aitk Chat App documentation
for more information.
In our example, we'll use a plugin to auto-select a model once they finish loading, so the user can start chatting immediately without manual selection.
The plugin uses Makepad's DeferWithRedraw trait, which requires an explicit import:
use makepad_widgets::defer_with_redraw::DeferWithRedraw;
struct AutoSelectPlugin {
ui: UiRunner<MyChat>,
initialized: bool,
}
impl ChatControllerPlugin for AutoSelectPlugin {
fn on_state_ready(&mut self, state: &ChatState, _mutations: &[ChatStateMutation]) {
if self.initialized || state.bots.is_empty() {
return;
}
let bots = state.bots.clone();
self.ui.defer_with_redraw(move |widget, _cx, _scope| {
widget.select_first_bot(&bots);
});
self.initialized = true;
}
}
The plugin watches for state changes. Once state.bots is populated (meaning the
Load task completed), it defers a UI action via Makepad's UiRunner to select the
first bot. UiRunner is Makepad's mechanism for safely deferring work back to the
widget that owns the runner.
The selection itself dispatches a mutation on the controller:
impl MyChat {
fn select_first_bot(&mut self, bots: &[Bot]) {
let Some(controller) = &self.controller else {
return;
};
if let Some(bot) = bots.first() {
controller
.lock()
.unwrap()
.dispatch_mutation(ChatStateMutation::SetBotId(Some(bot.id.clone())));
}
}
}
Putting it together
Register the plugin when building the controller, and wire everything in
on_after_new. Since the plugin uses UiRunner, you also need to add
self.ui_runner().handle(...) to your widget's handle_event:
impl Widget for MyChat {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.ui_runner().handle(cx, event, scope, self);
self.view.handle_event(cx, event, scope);
}
// draw_walk unchanged...
}
impl ScriptHook for MyChat {
fn on_after_new(&mut self, vm: &mut ScriptVm) {
let controller = ChatController::builder()
.with_basic_spawner()
.with_client(build_client())
.with_plugin_prepend(AutoSelectPlugin {
ui: self.ui_runner(),
initialized: false,
})
.build_arc();
controller.lock().unwrap().dispatch_task(ChatTask::Load);
self.controller = Some(controller.clone());
vm.with_cx_mut(|cx| {
self.chat(cx, ids!(chat))
.write()
.set_chat_controller(cx, Some(controller));
});
}
}
Custom Content
By default, Moly Kit renders bot messages using the StandardMessageContent widget,
which handles markdown, thinking blocks, and citations.
Some providers return content in formats that require specialized rendering beyond
what the standard widget supports. The CustomContent trait lets you provide your
own Makepad widget for these cases.
Prerequisites
This guide assumes you have read the Quickstart and are comfortable with Makepad widget development.
Overview
- Create a Makepad widget for your custom content.
- Implement the
CustomContenttrait. - Register it on the
Messageswidget insideChat.
Step 1: Create a widget
Create a standard Makepad widget that can display your content. It should use
height: Fit to avoid layout issues within the message list.
use makepad_widgets::*;
use moly_kit::prelude::*;
script_mod! {
use mod.prelude.widgets.*
mod.widgets.MyCustomContentBase = #(MyCustomContent::register_widget(vm))
mod.widgets.MyCustomContent = set_type_default() do mod.widgets.MyCustomContentBase {
height: Fit
label := Label {}
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct MyCustomContent {
#[deref]
deref: View,
}
impl Widget for MyCustomContent {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.deref.draw_walk(cx, scope, walk)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.deref.handle_event(cx, event, scope)
}
}
impl MyCustomContent {
pub fn set_content(&mut self, cx: &mut Cx, content: &MessageContent) {
self.label(cx, ids!(label)).set_text(cx, &content.text);
}
}
Step 2: Implement CustomContent
The CustomContent trait has a single method:
pub trait CustomContent {
fn content_widget(
&mut self,
cx: &mut Cx,
previous_widget: WidgetRef,
content: &MessageContent,
) -> Option<WidgetRef>;
}
Return Some(widget) when your implementation should handle the given content, or
None to fall through to the default rendering. The previous_widget parameter is
the widget currently in the slot -- reuse it when possible to preserve state.
Here is an example that checks content.data to decide whether to handle the
message:
pub struct MyContentProvider {
template: ScriptObjectRef,
}
impl MyContentProvider {
pub fn new(template: ScriptObjectRef) -> Self {
Self { template }
}
}
impl CustomContent for MyContentProvider {
fn content_widget(
&mut self,
cx: &mut Cx,
previous_widget: WidgetRef,
content: &MessageContent,
) -> Option<WidgetRef> {
// Only handle messages with specific data.
let data = content.data.as_deref()?;
if !data.contains("my_custom_format") {
return None;
}
// Reuse the existing widget if possible, otherwise create from template.
let widget = if previous_widget
.as_my_custom_content()
.borrow()
.is_some()
{
previous_widget
} else {
cx.with_vm(|vm| {
let value: ScriptValue = self.template.as_object().into();
WidgetRef::script_from_value(vm, value)
})
};
widget
.as_my_custom_content()
.borrow_mut()
.unwrap()
.set_content(cx, content);
Some(widget)
}
}
Step 3: Register it
Register the custom content provider on the Messages widget inside Chat:
self.chat(cx, ids!(chat))
.read()
.messages_ref(cx)
.write()
.register_custom_content(MyContentProvider::new(template));
You can register multiple CustomContent implementations. At draw time, the
Messages widget iterates through them in order. The first one to return
Some(widget) for a given message wins. If none match, the default
StandardMessageContent is used.
Real-world example
The Moly app uses this mechanism for its
DeepInquire integration. The DeepInquireCustomContent checks if a message's data
field contains DeepInquire-formatted JSON and, if so, renders it with a specialized
multi-stage widget instead of the standard markdown view.
Web Support
Moly Kit supports WebAssembly out of the box. To run your app in the browser:
cargo makepad wasm --bindgen run -p your_application_package
You will need to have the cargo makepad CLI installed. Check Makepad's
documentation for more information.
By default, Makepad uses its own glue code to work in a web browser and doesn't
work with wasm-bindgen out of the box.
The --bindgen argument passed to cargo makepad is important as it enables
wasm-bindgen interoperability in Makepad.
However, if you pass --bindgen but don't actually use wasm-bindgen anywhere
in your app, you may see errors on the browser console about missing values.
Crate Documentation
The moly-kit crate documentation generated by cargo doc is available
here.
To generate it locally:
cargo doc -p moly-kit --features full --no-deps --open