Tools and effects
Tools are the only way a plugin offers features. Each tool has an id, a title, a description for the model, a zod input schema and exactly one effect (read, write, money or public).
A tool is one typed operation that a plugin provides, such as "check the campus-card balance" or "create a reminder".
The same tool can be used in four ways. The agent calls it during a conversation. A screen calls
it with invoke when the user taps. A widget reads it with host.useToolData. A scheduled check
runs it. There is no other way to use a plugin's features, so the tools a plugin defines are
exactly the features it offers.
Anatomy of a tool
Here is the definition of the campus-card balance tool.
import { defineTool } from '@pku-harness/sdk';
import { z } from 'zod';
const cardBalance = defineTool({
title: '校园卡余额', // shown to users (chat chips, approvals, Settings)
description: 'Campus card balance in CNY, today\'s spending and the daily limit. Use when the user asks how much money is left on the card.',
input: z.object({}), // validated before execute runs
effect: 'read', // what it changes: read | write | money | public
execute: async (_input, ctx) => {
const res = await ctx.services.campus.request('card', { url: '/berserker-app/ykt/tsm/queryCard?synAccessSource=h5' });
return parseBalance(res.body); // plain JSON data, never instructions
},
});The rules for each field are as follows.
titleis a short Chinese title shown to users. Put the English title ini18n.en.tools.<name>.title.descriptionis written for the model. It tells the model when and how to use the tool, and gives units, formats and limits. You can write it in English or Chinese, and it is never translated. It can also be a function of host services (Dynamic<string>).inputis a zod schema. Add.describe()to each field, because the model sees the JSON Schema generated from it.effectis the tool's effect, one of the four in the next section. Choose it honestly.untrustedistruewhen the result contains text written by third parties, such as forum posts, notices or event descriptions.userOnlyistruefor an action that only the user may start and the agent must not open itself. The tool is hidden from the model, and calls from the agent or the scheduler are refused.executehas the form(input, ctx) => Promise<output>. On failure, throw anErrorwith a clear message that the user can understand.
Ids and names
A tool is written in two ways, one for code and one for the model.
The tool id has the form <plugin id>.<tool name>, for example wallet.cardBalance or
scheduler.create. Code always uses ids, for example in invoke('memory.list'),
host.useToolData('wallet.cardBalance'), check jobs and widget bindings.
The name the model sees is the id with its first . replaced by __, for example
wallet__cardBalance. This is because model APIs don't allow dots in tool names. When your
instructions or descriptions mention a tool, use this form, for example
"use canteen__openNow for …".
For remote plugins, the tool id is mcp-<server>.<tool> and the model sees
mcp-<server>__<tool>.
The four effects
An effect describes what a tool changes. Each tool has exactly one effect.
| Effect | Meaning | Examples |
|---|---|---|
read | Reads data and changes nothing | balance, timetable, search, list |
write | Changes state on the phone or on a service | add a to-do, create a reminder, follow a post, book a room |
money | Spends or moves money, or makes a payment possible | recharge the card, create a payment order, switch to the digital card for payment |
public | Publishes something that other people can see | post or reply on Treehole |
If more than one effect applies, pick the strongest. For example, a tool that posts publicly
and also changes local state has the effect public.
Effects decide permissions. For each effect of each plugin, you choose whether the agent may act directly, must ask first, or may not act at all.
Scheduled runs only read
Nobody is watching when a scheduled job runs. For this reason, the scheduler refuses every tool
whose effect is not read, whatever modes you have set. Only mark a tool read if it really
changes nothing. Otherwise, scheduled checks built on it will mislead users.
Who calls a tool
ctx.actor tells the tool who made the call. There are three kinds of caller.
| Actor | When | Approval |
|---|---|---|
user | A screen or widget called invoke(...) after a tap | Never asked |
agent | The model called the tool during a conversation | Asked when the effect's mode is ask |
scheduler | A scheduled check or agent job | Only read tools are allowed |
A call from the user is never questioned, because the tap itself shows what the user wants. When
the scheduler meets a tool whose mode is ask, it treats the call as declined.
When the agent calls a tool during a conversation, ctx.turn is set to
{ sessionId, tainted, userAsked }. The security model
explains what tainted means.
How to return results
- Return JSON that answers the question directly. Put units in field names, such as
balanceYuanandminutesUntil. Write times in ISO format or as local time"YYYY-MM-DD HH:mm". If the model may need to pass an id to the next tool, include the id. - Don't return instructions such as "Now tell the user…". The model treats results as data.
- Keep results small. In the model's history, the harness cuts each result to 12,000 characters, while the UI keeps the full output. Page or filter the data instead of returning all of it at once.
- For an expected state that the user can recover from, return a status object instead of
throwing. For example, when Treehole needs an SMS check, return
{ status: 'verification_required', methods: ['sms'], message: '…' }. - Never return secrets, such as payment codes, tokens or share links. The campus-card payment code is only ever shown on a screen, never in a tool result.
Data sources
Some read tools work well for widgets and scheduled checks. A plugin lists these tools in
PluginDef.dataSources and gives each one a cache lifetime. The agent finds them with
dashboard.sources.
dataSources: {
crowd: { title: '食堂就餐人数', tool: 'crowd', ttlMs: 2 * 60_000 },
},tool is the name of a tool inside this plugin. Fixed arguments, if any, go in args.
Plugins
Plugins come in three kinds. Plugin documents (PLUGIN.md) are interpreted by the app and installed by link. Bundled TypeScript plugins ship inside the app. Remote plugins are MCP servers added by URL. All three share one registry, one permission system and one agent loop.
Permissions and consent
For each effect of each plugin, you decide whether the agent may act directly, must ask first, or may not act at all. Scheduled runs only read.