PKU harness
Concepts

Host services

Whenever a bundled plugin needs to do something outside itself, it goes through `HostServices`. The harness passes it in as `ctx.services` in tools and as `props.services` in screens and widgets.

Bundled plugins never import app code. The system features a plugin needs, such as sending a campus request or creating a scheduled task, are provided by the app. We call these features host services.

The harness gives a plugin its host services in one HostServices object. Inside a tool it is ctx.services. In screens and widgets it is props.services.

Each plugin also gets its own key-value store (ctx.kv / props.kv). If the plugin declares the cloud, it also gets a cloud client (ctx.cloud).

Overview

ServiceWhat it provides
campusSigned-in campus HTTP requests and sign-in status
schedulerCreating, updating, removing and listing scheduled jobs
widgetsManaging dashboard widgets
todosTo-dos, optionally with reminders
timetableCourses, the semester and the class-reminder setting
notifyWhether reminders can fire on time, and a way to fix what's missing
smsOne-tap reading of SMS codes
filesThe system file picker, and the text of documents attached in the chat (list, read, search)
settingsEvery app and plugin setting: list, change, open its screen
phoneCopilot's phone sessions (for the bundled Copilot plugin)
browserOpening a URL in the system browser
cryptosha256(text)
registryInstalled plugins and tools (read-only)
remoteInstalling, uninstalling and checking remote MCP plugins
genuiThe generative-UI catalog and spec validation
ongoing?An Android foreground service with a persistent notification

The exact signatures are in SDK → HostServices. Here are some points to keep in mind when you use them.

  • campus centres on request(service, req). It can also return the current mobile-token (手机令牌) code, and it can hand off to the browser while keeping you signed in. It requires the campus-session capability. See Campus sessions.
  • scheduler is used for exact notifications and scheduled checks, and it can run a check immediately. See Scheduler.
  • widgets can list, put, remove, reorder, preview and resolve widgets, and it provides the component catalog. See Dashboard.
  • notify has a fix method that may only be called from a tap.
  • sms provides awaitCode(), which uses Android's SMS User Consent feature and needs no SMS permission.
  • files provides pick, which only the user can start. Never call it from a tool the agent calls. Its list, read and search reach the documents the user attached in the current chat (PDF, Word, Excel, PowerPoint, audio transcripts and so on) as text extracted on the phone. That text is third-party content, so tools that return it must be marked untrusted.
  • settings reads and writes the same settings as the Settings screens. change refuses unknown and uiOnly keys. A plugin's own settings are declared in PluginDef.preferences; see Architecture → The agent operates everything.
  • phone drives Copilot's separate screen. Only the bundled Copilot plugin uses it, and it needs the phone-control capability.
  • browser is for cashier pages, documents, and sign-ins that the harness can't do itself.
  • crypto exists because plugins have no cryptography library of their own.
  • remote is used by the plugins plugin.
  • ongoing exists only when the phone has the ongoing capability, which is why its name has a question mark.

Private storage: kv

interface KV {
  get<T>(key: string): Promise<T | undefined>;
  set<T>(key: string, value: T): Promise<void>;
  delete(key: string): Promise<void>;
}

Each plugin's kv has its own namespace. The values are stored as JSON in a SQLite database on the phone.

kv is not the keystore. Don't store secrets that the user typed in it, and don't store large blobs either.

Example: a tool that uses three services

This tool creates a scheduled check. Every morning at 8:00 it looks up the campus-card balance and notifies you if the balance is below an amount you choose. It uses campus, scheduler and kv.

import { defineTool } from '@pku-harness/sdk';
import { z } from 'zod';

export const remindLowBalance = defineTool({
  title: '余额提醒',
  description: 'Every morning at 08:00, notify when the campus card balance is below `below` yuan.',
  input: z.object({ below: z.number().positive() }),
  effect: 'write',
  execute: async ({ below }, ctx) => {
    if (!ctx.services.campus.status().signedIn) throw new Error('请先在设置里登录校园账号');
    const job = await ctx.services.scheduler.create({
      title: '校园卡余额提醒',
      source: ctx.actor === 'agent' ? 'agent' : 'user',
      trigger: { type: 'every', unit: 'day', interval: 1, times: ['08:00'], startAt: Date.now() },
      action: {
        type: 'check',
        tool: 'wallet.cardBalance',
        condition: { path: 'balance', op: '<', value: below },
        title: '校园卡余额只剩 ¥{value}',
      },
    });
    await ctx.kv.set('lowBalanceJob', job.id);
    return { jobId: job.id, nextRunAt: job.nextRunAt };
  },
});

Rules

  • You must reach the app only through services, kv, cloud and, in UI, host. Don't import from the app, and don't share global singletons between plugins.
  • You must not import another plugin's code. You can copy a small helper instead, which is what bundled plugins do for their HTTP helpers. Code that several bundled plugins need goes in plugins/shared/, for example campus-card.ts, which wallet and card-share both use.
  • You must call files.pick and notify.fix only in response to a tap from the user. Never call files.pick from a tool the agent calls.
  • You should use a plain fetch only for public pages that need no sign-in, and declare requires: ['network']. Every request that needs the user's session goes through services.campus.request.

On this page