PKU harness
Build a plugin

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:

NameRuleExample
Tool nameASCII camelCase verb or noun, unique in the plugincardBalance, search, reserveSeat
Tool id<plugin>.<name> (set automatically)wallet.cardBalance
Model name<plugin>__<name> (set automatically)wallet__cardBalance
titleShort 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__sendCode first"), and what to do on verification_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's scheduleInputSchema and parseLocal already use this format for schedules.
  • Remote plugins: the device converts the MCP inputSchema, which is JSON Schema, to zod with z.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 Treehole

Two 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 Error with a short Chinese message the user can read, for example throw 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 in i18n.en.tools, and a description for the model.
  • Every input field has limits and a description.
  • The effect is honest, and untrusted or userOnly is 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.

On this page