Skip to main content

Write a typed tool

This guide writes a typed tool: a component that exports its own interface, with real types and doc comments, and nothing from cosmonic:agent. The host turns that interface into tool definitions a model can call. The example converts temperatures, so it needs no network and no files.

Typed tools explains how the host translates between the model's JSON and your types.

Before you start​

  • Rust 1.94 or later with the wasm32-wasip2 target (rustup target add wasm32-wasip2).
  • wash 2.7.0 or later.
  • wasm-tools, to build the WIT package the tool's descriptions come from.
  • Cosmonic Desktop 0.5.32 or later, to offer the tool to agents on your machine.
  • Optional: a coding agent, such as Claude Code or Codex, if you'd rather it write and build the tool for you.

Build the tool​

Write it yourself, step by step, or hand it to a coding agent.

1. Create the project​

cargo new --lib units-tool
cd units-tool
mkdir wit

Replace Cargo.toml with:

[package]
name = "units-tool"
version = "0.1.0"
edition = "2024"

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

[dependencies]
wit-bindgen = "0.62.0"

[profile.release]
opt-level = "s"
lto = true
strip = true

2. Write the interface​

The interface is the tool. Write it in WIT (WebAssembly Interface Types), the interface language components use, much as a .proto file describes a gRPC service. Save it as wit/units.wit:

/// Convert between units of measure. Needs no network and no files.
package example:units@0.1.0;

/// Temperature conversions.
@since(version = 0.1.0)
interface temperature {
  /// A temperature scale.
  enum scale {
    /// Degrees Celsius.
    celsius,
    /// Degrees Fahrenheit.
    fahrenheit,
    /// Kelvin.
    kelvin,
  }

  /// Why a conversion failed.
  variant error {
    /// The value is below absolute zero. The payload says by how much.
    below-absolute-zero(string),
  }

  /// Arguments for `convert`.
  record convert-args {
    /// The temperature to convert.
    value: f64,
    /// The scale `value` is in.
    %from: scale,
    /// The scale to convert to.
    to: scale,
  }

  /// What `convert` returns.
  record convert-result {
    /// The converted temperature, rounded to two decimal places.
    value: f64,
  }

  /// Convert a temperature from one scale to another. Use it rather than
  /// doing the arithmetic yourself.
  convert: async func(args: convert-args) -> result<convert-result, error>;
}

/// A component that converts units, reaching nothing.
@since(version = 0.1.0)
world converter {
  export temperature;
}

Follow these rules, because the host builds the model's view of the tool from them:

  • Each exported function is one tool. Its name, in snake case, is the tool's name (convert). Its doc comment is the tool's description, written for the model: say what it does and when to use it.
  • Take one record. WIT has no doc comments on parameters, but it has them on record fields. When a function's only parameter is a record, the host offers its fields (value, from, to) as the tool's arguments, each described by its comment.
  • Use real types. An enum becomes a fixed list of choices, an option<T> field may be left out (write its default in the comment), and the host checks the model's JSON against the types before your code runs.
  • Return a result. Its err goes back to the model as an error it can read and react to, not as a failed call.
  • The world is the reach. This world imports nothing, so the tool can reach nothing. A tool that calls a web API would import wasi:http/client@0.3.0, and one that reads a folder would import wasi:filesystem; each host then decides which hosts or folder that import can actually touch.

Functions that take or return resources, streams or futures have no JSON form and are not offered as tools.

3. Implement it​

Replace src/lib.rs with:

mod bindings {
    wit_bindgen::generate!({
        path: "wit",
        world: "converter",
    });
}

use bindings::exports::example::units::temperature::{
    ConvertArgs, ConvertResult, Error, Guest, Scale,
};

/// Every scale through kelvin.
fn to_kelvin(value: f64, scale: Scale) -> f64 {
    match scale {
        Scale::Celsius => value + 273.15,
        Scale::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
        Scale::Kelvin => value,
    }
}

fn from_kelvin(kelvin: f64, scale: Scale) -> f64 {
    match scale {
        Scale::Celsius => kelvin - 273.15,
        Scale::Fahrenheit => (kelvin - 273.15) * 9.0 / 5.0 + 32.0,
        Scale::Kelvin => kelvin,
    }
}

struct Component;

impl Guest for Component {
    async fn convert(args: ConvertArgs) -> Result<ConvertResult, Error> {
        let kelvin = to_kelvin(args.value, args.from);
        if kelvin < 0.0 {
            return Err(Error::BelowAbsoluteZero(format!(
                "{:.2} below absolute zero",
                -kelvin
            )));
        }
        let value = (from_kelvin(kelvin, args.to) * 100.0).round() / 100.0;
        Ok(ConvertResult { value })
    }
}

bindings::export!(Component with_types_in bindings);

Nothing here mentions agents, models or JSON. It is an ordinary library with a WIT interface.

4. Build it and check its reach​

cargo build --target wasm32-wasip2 --release
wash inspect target/wasm32-wasip2/release/units_tool.wasm

The output ends with the one export, example:units/temperature@0.1.0. The imports above it are the console, clock and exit interfaces that Rust's standard library brings in. There is no wasi:http, no wasi:sockets and no wasi:filesystem.

5. Build the WIT package​

A built component keeps its types but not its doc comments, so the descriptions travel in a separate, published WIT package. Build it from the same WIT:

wasm-tools component wit wit --wasm -o units-wit.wasm

units-wit.wasm is the package's binary encoding, with your doc comments in it. A host uses it only when its interface matches the component's types exactly.

6. Push it to Desktop's registry​

Push the component and the WIT package to the registry built into Desktop, and note the digest each push prints:

wash oci push -o json --insecure oci.localhost:8200/tools/units:0.1.0 \
  target/wasm32-wasip2/release/units_tool.wasm
wash oci push -o json --insecure oci.localhost:8200/tools/wit/units:0.1.0 \
  units-wit.wasm

--insecure is there because the built-in registry speaks plain HTTP on your own machine.

Offer it in Cosmonic Desktop​

Add the tool to Desktop's tool catalog. These steps use the temperature tool's names. If your coding agent built the lengths tool, use the references it gave you and the name lengths.

  1. In Desktop, select Settings in the sidebar, then Tools under Agent workspace, and select Add a tool.
  2. Under Where it comes from, choose Image.
  3. Fill in:
    • Image reference: the component, pinned by digest, such as oci.localhost:8200/tools/units@sha256:…. A tag alone is refused, so the tool can't change under you.
    • WIT package: the WIT package, pinned by digest, such as oci.localhost:8200/tools/wit/units@sha256:…. Without it, agents see the tool's functions by name only.
    • Hosts it may reach: leave it empty. This tool needs no network.
    • Leave It reads a folder you choose for each session unchecked.
    • Name: units. Title: Unit converter.
  4. Select Review. Under What the agent receives, check that convert is listed with the descriptions you wrote. If Desktop says agents see these tools by name only, the WIT package is missing or doesn't match the component.
  5. Select Add tool.

Unit converter is now in Add tools yourself… when you start a session, and an agent can find it with Let the agent find tools on. A tool that calls a web API lists its hosts in Hosts it may reach, and Desktop shows them in the review and on the tool's card.

Offer it in Cosmonic Control​

On Control, a typed tool is a component in the agent's workload. Push it to a registry your cluster can pull from, add it to the workload, and name it in tool-components:

hostInterfaces:
  - namespace: cosmonic
    package: agent
    interfaces: [tools]
    version: 0.4.1
    name: cosmonic-agent-tools
    config:
      components: agent
      tool-components: units
components:
  - name: units
    image: ghcr.io/you/units-tool@sha256:…

The tool reaches nothing, so it needs no allowedHosts. Give an agent a tool covers hosts, folders and stores.