PKU harness
Concepts

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.

  • title is a short Chinese title shown to users. Put the English title in i18n.en.tools.<name>.title.
  • description is 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>).
  • input is a zod schema. Add .describe() to each field, because the model sees the JSON Schema generated from it.
  • effect is the tool's effect, one of the four in the next section. Choose it honestly.
  • untrusted is true when the result contains text written by third parties, such as forum posts, notices or event descriptions.
  • userOnly is true for 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.
  • execute has the form (input, ctx) => Promise<output>. On failure, throw an Error with 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.

EffectMeaningExamples
readReads data and changes nothingbalance, timetable, search, list
writeChanges state on the phone or on a serviceadd a to-do, create a reminder, follow a post, book a room
moneySpends or moves money, or makes a payment possiblerecharge the card, create a payment order, switch to the digital card for payment
publicPublishes something that other people can seepost 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.

ActorWhenApproval
userA screen or widget called invoke(...) after a tapNever asked
agentThe model called the tool during a conversationAsked when the effect's mode is ask
schedulerA scheduled check or agent jobOnly 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 balanceYuan and minutesUntil. 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.

On this page