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
| Service | What it provides |
|---|---|
campus | Signed-in campus HTTP requests and sign-in status |
scheduler | Creating, updating, removing and listing scheduled jobs |
widgets | Managing dashboard widgets |
todos | To-dos, optionally with reminders |
timetable | Courses, the semester and the class-reminder setting |
notify | Whether reminders can fire on time, and a way to fix what's missing |
sms | One-tap reading of SMS codes |
files | The system file picker, and the text of documents attached in the chat (list, read, search) |
settings | Every app and plugin setting: list, change, open its screen |
phone | Copilot's phone sessions (for the bundled Copilot plugin) |
browser | Opening a URL in the system browser |
crypto | sha256(text) |
registry | Installed plugins and tools (read-only) |
remote | Installing, uninstalling and checking remote MCP plugins |
genui | The 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.
campuscentres onrequest(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 thecampus-sessioncapability. See Campus sessions.scheduleris used for exact notifications and scheduled checks, and it can run a check immediately. See Scheduler.widgetscan list, put, remove, reorder, preview and resolve widgets, and it provides the component catalog. See Dashboard.notifyhas afixmethod that may only be called from a tap.smsprovidesawaitCode(), which uses Android's SMS User Consent feature and needs no SMS permission.filesprovidespick, which only the user can start. Never call it from a tool the agent calls. Itslist,readandsearchreach 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 markeduntrusted.settingsreads and writes the same settings as the Settings screens.changerefuses unknown anduiOnlykeys. A plugin's own settings are declared inPluginDef.preferences; see Architecture → The agent operates everything.phonedrives Copilot's separate screen. Only the bundled Copilot plugin uses it, and it needs thephone-controlcapability.browseris for cashier pages, documents, and sign-ins that the harness can't do itself.cryptoexists because plugins have no cryptography library of their own.remoteis used by thepluginsplugin.ongoingexists only when the phone has theongoingcapability, 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,cloudand, 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 examplecampus-card.ts, whichwalletandcard-shareboth use. - You must call
files.pickandnotify.fixonly in response to a tap from the user. Never callfiles.pickfrom a tool the agent calls. - You should use a plain
fetchonly for public pages that need no sign-in, and declarerequires: ['network']. Every request that needs the user's session goes throughservices.campus.request.
Capabilities
Plugins declare in `requires` what they need from the phone. If the phone can't provide it, the harness hides the plugin and tells the user why.
Generative UI
The agent describes a piece of UI in JSON, and the app shows it with native components, either in the chat (`ui.render`) or on the dashboard. A submitted form comes back to the agent as your next message.