Tools
Designing tools that the voice model and the assistants use well.
Interface 0.2, and 0.1, which Niles still runs. Niles 0.2.0 runs them and installs them from GitHub: the runtime,
niles plugin check,niles plugin dev, Add-on from GitHub with its approval screen, updates, rollback, turning off and removing, and in the app the generated setup panel, the plugin's log, and its pages and widgets.
A tool is a function the AI can decide to call. Niles offers your tools to its
voice model, which answers spoken requests, and to the Claude and ChatGPT
assistants in the app's chat (through its MCP endpoint). Needs tools = true.
fn tools() -> Vec<ToolDef> {
vec![ToolDef {
name: "next_departures".into(),
description: "The next buses from the stop near the house, with minutes \
until each leaves. Use for \"when's the next bus\", \"do I need to \
run\", \"is the 5A on time\"."
.into(),
schema: serde_json::json!({
"type": "object",
"properties": {
"line": {
"type": "string",
"description": "A line to filter by, such as \"5A\". All lines if left out."
}
},
"additionalProperties": false
})
.to_string(),
}]
}How the model sees your tool
- Name:
<plugin-id>_<name>, sorejseplanen_next_departures. Yourcall_toolreceives the barenext_departures. Names are[a-z0-9_]+, and the whole thing at most 64 characters. A name that would clash with one of Niles's tools or another plugin's is refused at install. - Description: the only thing the model reads to decide whether to call it and how. At most 1 KiB. To point at a sibling tool, use the name the model sees, with the prefix: "It can be resumed with bambulab_resume_print", not "with resume_print", which names no tool the model has.
- Schema: a JSON Schema object for the arguments, at most 8 KiB.
Writing a tool the model uses well
The model that answers spoken requests is fast rather than clever, and every tool's text is in its prompt. That shapes everything:
- Few tools, each doing one thing a person would ask for. "next_departures" beats "query_api(endpoint, params)". At most 16 per plugin; most need one to three.
- Say when to use it, in the words people use. Put sample phrasings in the description: "Use for 'when is power cheapest', 'should I run the dishwasher now'." The model matches requests to these.
- Say what it does not do, when it is close to something it does not: "Spot price only: tariffs and taxes come on top."
- Units in names and descriptions:
kr_kwh,minutes,celsius. Use Niles's units (percent 0–100, kelvin, °C). - Small, typed parameters.
enumwherever the values are known ("day": {"enum": ["today", "tomorrow"]}),minimum/maximumon numbers, adescriptionon each property,additionalProperties: false, and as fewrequiredones as possible. Defaults belong in your code and the description ("today if left out"), because models fill in what the schema allows. - No ids the model has to invent. If a parameter is "which device", accept the name people say and resolve it yourself.
Relevance keywords
Every tool's schema is sent with every request it might be needed for, and
that costs. Niles measured one call at 3,497 tokens of an 8,000-token-a-minute
budget with thirty-odd tools, most of them irrelevant to the request. So Niles
sends tools by subject: each subject has words that signal it, and a request
gets the tools whose words it contains (see
crates/niles-tools/src/relevance.rs). A request that matches nothing gets
every tool, because guessing wrong there hurts most.
Your plugin's tools are one subject, signalled by the manifest's keywords:
keywords = ["bus", "buses", "departure", "departures", "5a", "next bus", "rejseplanen"]- Required if you have tools. Unlike Niles's own tools, a plugin's tools are never sent "just in case", because ten plugins doing that would fill the prompt for every light switched.
- Whole words, matched case-insensitively, and phrases:
next busmatches "when's the next bus?" butbusdoes not match "business". - The words people say, in the languages they say them in. A Danish household says "strøm" and "elpris" as often as "electricity". Plurals are separate words.
- Not too broad. "on", "what", "is" match half of everything and cost every household your tokens.
Returning results
call_tool returns Ok(text) for the model to read, or Err(text) for a
failure.
- Compact JSON or one short sentence. The model turns it into speech. Give it the facts it needs and nothing else: no HTML, no 200-field API response passed through, no prose padding.
- Do the arithmetic. If the question is "when is it cheapest", return hourly prices or the cheapest window, not 96 quarter-hour records.
- Include units, as field names (
kr_kwh) or aunitfield. - At most 16 KiB. Longer results are cut, with a note to the model.
- Errors for people.
Errtext is told to the model as the tool's failure, and usually read out: "The bus service is not answering", notHttpError::Connection("tcp connect error"). Log the details instead. - Results are data, not instructions. The model is told that tool results come from a tool. Do not write instructions to it in a result ("Tell the user to…"); put guidance in the description.
Tools that change things
A tool that acts (books, orders, sends, deletes) should say so in its description, and the action should be safe to repeat or easy to undo. Niles asks for confirmation before its own consequential actions; the model will usually do the same for yours if the description says "Confirm with the user before calling." Do not make a tool that spends money or deletes data without that line.
Who is asking
call_tool does not say which person or room a request came from. A voice
request's speaker is identified by Niles, and v0.1 does not pass that on to
plugins (pages get the signed-in person's slug, nothing more; see
pages and widgets). If a tool needs a room, take it as a
parameter: the model knows which room the satellite is in.