Skip to content

Actions

Each window-level action becomes an MCP tool named {winId}__{action}. Build one with win.actions().add(name, title), refine it, and finish with .on(handler).

Builder options

MethodEffect
.description(s)Human-readable description in tools/list
.params::<T>()Generates inputSchema from T: JsonSchema + DeserializeOwned
.returns::<R>()Contributes the result sub-schema of outputSchema
.read_only() .destructive() .idempotent() .open_world()MCP annotation hints
.available(bool)Initial visibility in tools/list

Handler forms

.on(..) accepts six shapes. Pick by two questions: does it need parameters? and does it need the host context &mut Cx?

FormRuns onUse for
on_ui(|| …)Main threadPure UI logic, no args
on_ui(|p| …)Main threadPure UI logic with args
on_ui(|cx| …)Main threadTouching real widgets, no args
on_ui(|p, cx| …)Main threadTouching real widgets with args
|| async { … }tokioAsync work, no args
|p| async { … }tokioAsync work with args
rust
#[derive(Deserialize, JsonSchema)]
struct Add { text: String }

#[derive(Serialize, JsonSchema)]
struct Stats { total: usize, done: usize }

win.actions()
    .add("add", "Add a todo")
    .description("Append a new open item.")
    .params::<Add>()      // → inputSchema
    .returns::<Stats>()   // → outputSchema
    .on(on_ui(move |p: Add, _cx: &mut Cx| {
        let text = p.text.trim().to_string();
        if text.is_empty() {
            return Err(ActionError::field("text", "must not be empty"));
        }
        // …mutate real state, then let the read side mirror it…
        Ok(Outcome::message("added").with(Stats { total: 1, done: 0 }))
    }));

Returning results

Return &str, String, or an Outcome. Outcome::message(..).with(value) attaches structured data the agent can parse instead of re-reading the whole snapshot.

Async handlers and the main thread

Important

An async handler runs on tokio and therefore receives no &mut Cx. To touch the UI after awaiting, capture a Ui<Cx> (from app.ui()) and hop back:

rust
let ui = app.ui();
win.actions().add("report", "Build a report").on(move || {
    let ui = ui.clone();
    async move {
        let data = fetch_from_network().await;          // off-thread work
        let n = ui.run(move |cx| apply(cx, data)).await; // back on the main thread
        Ok::<_, ActionError>(Outcome::message(format!("{n} rows")))
    }
});

Changing actions at runtime

.on(..) returns an ActionHandle. Keep it to hide a tool the agent should not currently call — the tool list is recomputed and clients are notified:

rust
let clear = win.actions().add("clear_done", "Clear completed")
    .available(false)                       // hidden until useful
    .on(on_ui(|| Ok::<_, ActionError>("cleared")));

// In sync(), as state changes:
clear.set_available(self.items.iter().any(|t| t.done));

ActionHandle also has set_title, set_description and remove.

Why bother

An agent that only ever sees callable tools does not need to guess, and cannot waste a turn on an action that would fail.