Tools and schemas
How to design tools the model uses well. Covers naming, descriptions, inputs in zod (bundled plugins) or JSON Schema (MCP), honest effects, results that are data, and states the user can recover from.
Tools are a plugin's API. The model calls them, and so do your own screens. A good tool is small and does one thing. Its description says exactly when to use it, and it returns data the model can act on.
This page shows how to write tools in code, for bundled plugins (zod) and remote plugins (JSON Schema). For tools in a plugin document, see Declarative plugins; the advice on descriptions, effects and results applies there too. The ideas behind tools are explained in Tools and effects.
Naming
A tool has several names, each for a different reader:
| Name | Rule | Example |
|---|---|---|
| Tool name | ASCII camelCase verb or noun, unique in the plugin | cardBalance, search, reserveSeat |
| Tool id | <plugin>.<name> (set automatically) | wallet.cardBalance |
| Model name | <plugin>__<name> (set automatically) | wallet__cardBalance |
title | Short Chinese label for users | 校园卡余额 |
The app sends every enabled tool to the model with every request. Many similar tools therefore cost tokens on every request. Fold related reads into one tool with a filter argument instead of writing ten nearly identical ones.
Text for people
Users see a tool's title in chat, in approval prompts and in Settings. Write it in Chinese and put
the English version in i18n.en.tools.<name>.title.
Plugin documents can also give a tool a summary: one line of at most 80 characters that says what
the tool does in plain words. The install review sheet and the marketplace's plugin page show it
under the title. The English version goes in i18n.en.tools.<name>.summary. If the English version
is missing, the app shows English users no summary line. Bundled plugins have no summary field.
The description, covered next, is never shown to users.
Descriptions
description is written for the model. You can write it in English or Chinese. It should cover
three things:
- When to use the tool, in the words a student would use. For example: "Use when the user asks which canteen is least crowded".
- What comes back: the fields, their units (yuan, minutes, Beijing time), and how fresh the data is.
- Limits and follow-ups: page sizes, a required order ("call
treehole__sendCodefirst"), and what to do onverification_required.
Don't repeat the input schema in the description. Describe each field with .describe() instead,
because the model sees those descriptions next to the fields.
When the text depends on the device, description can be a function that receives the host
services:
description: (services) => `Show a native UI in the chat… Components:\n${services.genui.describeCatalog()}`,Inputs
Bundled plugins use zod 4. The model sees the JSON Schema generated from the zod schema. Every call,
whether from the assistant, a screen or the scheduler, is validated by the app before execute
runs, so execute can trust its input.
input: z.object({
from: z.string().trim().max(20).optional().describe('boarding place, e.g. 燕园 / 新燕园'),
at: z.string().optional().describe('Beijing time "YYYY-MM-DD HH:mm", default now'),
count: z.number().int().min(1).max(10).optional().describe('departures per direction, default 3'),
}),- Put limits on every number and string (
.min,.max), and state defaults in the description. - Use an enum for a closed set of values, for example
z.enum(['weekday', 'holiday']). - When the model has to write a time, ask for a local time string,
"YYYY-MM-DD HH:mm". The SDK'sscheduleInputSchemaandparseLocalalready use this format for schedules. - Remote plugins: the device converts the MCP
inputSchema, which is JSON Schema, to zod withz.fromJSONSchema. Use only plain JSON Schema features so the conversion works.
Effects
Each tool has exactly one effect. The effect tells the app what kind of thing the tool does, and users decide for each effect whether the assistant may act without asking (see Permissions).
effect: 'read', // a balance, a list, a search
effect: 'write', // add a to-do, follow a post, book a room
effect: 'money', // create a payment order, top up, switch the card to digital mode
effect: 'public', // post or reply on TreeholeTwo more flags apply to some tools. Set untrusted: true on a tool that returns third-party text,
such as posts or notices. Calling it marks the rest of the assistant's turn as tainted, and
lasting writes in a tainted turn wait for the user (see Security).
Set userOnly: true on an action that only the user may start, such as approving or unlocking
something. The tool is then hidden from the model, and calls from the assistant or the scheduler are
refused. Set onDemand: true on a rarely needed tool: it is not offered when a chat starts, only
after the assistant loads the plugin with plugins.use, which keeps every request smaller.
Results
execute: async ({ name, sort = 'least' }, ctx) => {
const { time, canteens } = await fetchCrowd(ctx.signal);
return { time, canteens: canteens.filter((c) => !name || c.name.includes(name)), source: CROWD_SOURCE };
},The result goes back to the model, so shape it for the model to read:
- Return JSON objects whose field names and units explain themselves.
- When it matters, say where the data came from and how fresh it is (
source,time). - Return ids that the model can pass to the next tool.
- Pass
ctx.signal, the abort signal, to network calls, so a cancelled call stops quickly. - On failure, throw an
Errorwith a short Chinese message the user can read, for examplethrow new Error('校园卡登录已失效'). The assistant passes it on to the user.
Some situations are expected and need the user to step in, such as an SMS check. For these, return a status object instead of throwing. The assistant can then ask the user for what is needed and call the tool again:
export type VerificationRequired = { status: 'verification_required'; kind: 'login'; methods: ['sms']; message: string };
// in execute:
if (needsSms) {
return {
status: 'verification_required',
kind: 'login',
methods: ['sms'],
message: '树洞需要短信验证。先调用 treehole__sendCode 发送验证码,再带上 code 重试。',
} satisfies VerificationRequired;
}Context and instructions
A plugin can give the model two kinds of extra text.
instructions is added to the system prompt and sent with every request. Use it for short rules:
which tool answers which question, and caveats such as "以预约系统为准".
context({ query, kv, services }) runs before each turn and returns facts relevant to the current
message, such as a snapshot of the user's memory. The result is marked as data, and the function
has 800 ms to answer. Return undefined when nothing applies.
Both cost tokens on every request. Both also change the start of the prompt, which reduces how often the model provider can reuse its cache. Keep them short and stable. Leave out the date and time: the app adds them to every turn.
Calling your tools from UI
Screens and widgets call tools too. invoke runs a tool as the user, and host.useToolData reads a
tool's result through the app's shared cache:
// in a screen or widget (actor: 'user', no approval prompt)
await invoke('focus.start', { label: '高数' });
// live read with the host's shared cache
const data = host.useToolData<{ minutes: number }>('focus.today', {}, { ttlMinutes: 1 });Checklist
- Each tool has one responsibility, and reads and writes are separate tools.
- Each tool has a Chinese
title, an English title ini18n.en.tools, and adescriptionfor the model. - Every input field has limits and a description.
- The
effectis honest, anduntrustedoruserOnlyis set where it applies. - Results are data: small, with units, and with no secrets or instructions.
- Errors are short messages the user can read, and recoverable states are status objects.
Bundled plugin
Write a plugin that ships inside the app with definePlugin. A complete example walks through tools, a data source, a screen, a Settings row, a widget, a command, English text and tests.
UI contributions
How a bundled plugin adds screens, Settings rows, tab content, dashboard widgets and slash commands through `contributes`, using the components the app passes in as HostUI.