# Write a plugin with the AI (/en/docs/build/ai-authoring)
> Ask the assistant in the app to write a PLUGIN.md plugin with you. It reads the language reference, validates the draft, tries read-only tools against the live service, and opens the usual review sheet, where you decide whether to install.
A plugin document is plain text, so the AI assistant in the app can write one with you. For
example, you can ask for "a plugin for the gym booking site". The result is a `PLUGIN.md` folder
that you review and then install, or discard.
You need only the app and a configured AI model. No computer is required, although you will want
one later if you publish the plugin.
The assistant does this work through the tools of a built-in plugin called **Plugins** (id
`plugins`). None of these tools can install anything without your confirmation.
## The tools
The assistant uses five tools. The first four only read, and the last one opens a sheet for you.
**`plugins.reference`** (read) returns the whole plugin language on one page: the manifest, steps,
expressions, the standard library, host abilities, limits and an example. The assistant reads it
when it is about to write or edit a plugin. It is not part of every prompt, so it takes up no space in
other conversations.
**`plugins.validate`** (read) checks a draft folder with the same loader the app uses. The draft is
passed as `{ files: { "PLUGIN.md": "…", "tools/x.yaml": "…" } }`. The tool returns
`{ ok: true, id, version, tools, widgets, campus, network, abilities }` when the draft is valid. Otherwise
it returns `{ ok: false, issues }`, and each issue names the file and step where the problem is.
**`plugins.tryTool`** (read) runs one **read** tool of a draft for real, without installing the
draft. It sends live requests, uses your campus sessions for the services the draft declares, and
gives the tool a throwaway store. Tools with the `write`, `money` or `public` effect can't be tried
this way. The results are treated as untrusted data.
**`plugins.source`** (read) returns the files of an installed plugin document, so the assistant can
explain, fix or copy it. Built-in TypeScript plugins have no document, so this tool doesn't work for
them.
**`plugins.install`** (write) opens the app's review sheet for a link (a GitHub folder or a
`PLUGIN.md` URL) or for a draft (`{ files }`). It only opens the sheet. You make the decision there.
The Plugins plugin asks before it runs `write` tools by default. Unless you have changed that mode,
the assistant therefore also checks with you before it opens the sheet.
## How a plugin is written
### Describe what you want
Say what the plugin should do. If you know which page or API it should use, say that too. If it is a
campus service, mention it: the plugin will call the service with your signed-in session and will
never see your password.
### The assistant drafts and validates
These tools are not offered when a chat starts: the assistant first loads the authoring tools of the
`plugins` plugin with `plugins.use`. Then it reads `plugins.reference`, writes `PLUGIN.md` and the
tool files. It calls `plugins.validate` after each change and keeps fixing the draft until no issues are left.
### It tries the read tools
`plugins.tryTool` runs a read tool against the real service. You see actual results before anything
is installed, which tells you whether the plugin really works.
### You review and install
`plugins.install` opens the same review sheet you see for an install link. It shows whether the
plugin is official or community; the campus services, websites and app features it can reach; and
every tool with its effect. A draft written in chat counts as a community plugin. Nothing is
installed until you tap *Install*.
## Changing an installed plugin
To change an installed plugin document, ask the assistant to fix or extend it. It reads the files
with `plugins.source`, edits them, validates the result, and opens the review sheet again with the
new version.
If you paste a plugin link in the chat, the assistant opens the review sheet for it with
`plugins.install { link }`.
## Share what you made
The assistant's draft is an ordinary plugin folder. To share it, or to let it update from a link,
put the folder in a public GitHub repository. The folder's URL becomes its install link (see
[Your first plugin](/en/docs/build/quickstart#share-it)). Before you publish, run the
[author CLI](/en/docs/build/declarative-plugins#test) on a computer and add recorded tests.
## Safety
- The assistant never installs anything by itself. `plugins.install` only opens the review sheet.
- `plugins.tryTool` runs only read tools, reaches only what the draft declares, and keeps nothing.
- An installed draft follows the same rules as any community plugin. Its `write`, `money` and
`public` tools ask first, and its results reach the AI as data, never as instructions.
The language itself is covered in [Declarative plugins](/en/docs/build/declarative-plugins).
[Permissions](/en/docs/concepts/permissions) explains how the permission modes work.
# Bundled plugin (/en/docs/build/bundled-plugin)
> Write a plugin that ships inside the app with definePlugin. A complete example walks through tools, a data source, a screen, a Settings row, a widget, a command, English text and tests.
A bundled plugin is a TypeScript module in `pku-harness-plugins/plugins/` that depends only on
`@pku-harness/sdk`. It runs on the phone inside the app. It reaches the rest of the app only through
_host services_: objects the app passes to every plugin for campus requests, notifications, to-dos,
the scheduler and so on. Unlike a plugin document, it can add native UI.
Bundled plugins reach users only with app releases, so **open an issue first**. In the issue, say
what the plugin does and why it can't be a plugin document or a remote plugin.
TypeScript is used for the app's own features: UI, dashboard, scheduler, to-dos, schedule,
timetable, memory, plugin management, model providers, the authenticator and the payment-code
screen. It is also used for integrations the plugin language can't express yet. The official
campus integrations classrooms, campus-events, grades, canteen, shuttle and notices are
[plugin documents](/en/docs/build/declarative-plugins) bundled with the app, and the rest are being
ported. If your plugin sends campus or public requests and extracts data, write a `PLUGIN.md`
instead. If it needs something the language lacks, propose a host ability.
On this page you build a complete plugin called `focus`. It records focus sessions, such as an hour
of homework, and shows how many minutes you focused today. The plugin calls no external service, so
you can run it as it is. For a campus service, you would replace the storage calls with
`ctx.services.campus.request(...)` (see [Campus sessions](/en/docs/concepts/campus-sessions)).
You need bun, TypeScript and some React Native. Steps 1 to 6 happen in the plugins repository on your
computer. The last section runs the plugin in the app on an emulator or phone.
## Layout
The plugin lives in its own folder under `plugins/`. Outside that folder you change two shared
files: `plugins/index.ts`, the list of bundled plugins, and `registry/registry.json`.
## 1. Package
```json title="plugins/focus/package.json"
{
"name": "@pku-harness/plugin-focus",
"private": true,
"version": "0.1.0",
"description": "Focus sessions and today's total, on the device",
"type": "module",
"main": "src/index.ts",
"license": "MIT",
"pkuHarness": { "builtin": true },
"peerDependencies": {
"@pku-harness/sdk": "*",
"zod": "^4.0.0",
"react": "*",
"react-native": "*"
}
}
```
Every plugin is a package in the plugins workspace, and the workspace resolves `@pku-harness/sdk` to
`packages/sdk/src`. All packages are private, and nothing is published to npm. The dependencies are
peer dependencies because the app provides them. Talk to us before you add another runtime
dependency, because everything ships in the app bundle and makes it larger.
## 2. Pure logic first
Start with the logic that doesn't need React Native, and put it in plain modules. Modules that
import `react-native` can't be loaded by `bun test` without mocks, so keeping the logic separate
makes it easy to test.
Here, `store.ts` saves sessions in the plugin's key-value (KV) storage and works out today's
minutes. `minutesOn` is a pure function: it takes the time `now` as an argument instead of reading
the clock, so a test can pass any time it likes.
```ts title="plugins/focus/src/store.ts"
import type { KV } from '@pku-harness/sdk';
export type Session = { id: string; label: string; startedAt: number; endedAt: number | null };
const KEY = 'sessions';
export async function loadSessions(kv: KV): Promise {
return (await kv.get(KEY)) ?? [];
}
export async function saveSessions(kv: KV, list: Session[]): Promise {
await kv.set(KEY, list.slice(-500));
}
/** Minutes of focus on the local day of `now`, counting a running session up to `now`. Pure. */
export function minutesOn(list: Session[], now: number): number {
const d = new Date(now);
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
let ms = 0;
for (const s of list) {
const a = Math.max(s.startedAt, start);
const b = Math.min(s.endedAt ?? now, end);
if (b > a) ms += b - a;
}
return Math.round(ms / 60_000);
}
export const running = (list: Session[]) => list.find((s) => s.endedAt == null) ?? null;
```
## 3. The plugin
`src/index.ts` puts the parts together. It defines three tools (`start`, `stop` and `today`), a
data source for the widget, and the screen, Settings row, widget and command the plugin adds to the
app.
```ts title="plugins/focus/src/index.ts"
import { definePlugin, defineTool } from '@pku-harness/sdk';
import { z } from 'zod';
import { FocusScreen } from './screen';
import { loadSessions, minutesOn, running, saveSessions } from './store';
import { FocusWidget } from './widget';
/** 专注: start/stop focus sessions and see today's total. Everything stays on the device. */
export const focusPlugin = definePlugin({
id: 'focus',
name: '专注',
description: '记录专注时段,查看今天专注了多久。',
version: '0.1.0',
icon: 'clock',
requires: [],
scopes: [],
i18n: {
en: {
name: 'Focus',
description: 'Track focus sessions and see how long you focused today.',
tools: { start: { title: 'Start focusing' }, stop: { title: 'Stop focusing' }, today: { title: 'Focus today' } },
widgets: { today: { title: 'Focus today', description: 'Minutes focused today and the running session, if any.' } },
screens: { main: { title: 'Focus' } },
settings: { main: { title: 'Focus', subtitle: 'Focus sessions' } },
commands: { focus: { description: 'Open focus sessions', aliases: ['pomodoro'] } },
dataSources: { today: { title: 'Focus today' } },
},
},
instructions: `专注记录:用户说"开始学习/专注""我学完了"时用 focus__start / focus__stop;问"今天学了多久"时用 focus__today。`,
dataSources: {
today: { title: '今日专注', tool: 'today', ttlMs: 60_000 },
},
contributes: {
screens: { main: { title: '专注', component: FocusScreen } },
settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
widgets: {
today: {
title: '今日专注',
description: '今天专注的分钟数,以及正在进行的专注。',
sizes: ['sm', 'md', 'lg'],
defaultSize: 'sm',
component: FocusWidget,
},
},
commands: {
focus: { description: '打开专注', aliases: ['专注', '番茄钟'], run: (_input, { navigate }) => navigate('/p/focus/main') },
},
},
tools: {
start: defineTool({
title: '开始专注',
description: 'Start a focus session now. Fails if one is already running (stop it first).',
input: z.object({ label: z.string().trim().max(40).optional().describe('what the user focuses on, e.g. "高数作业"') }),
effect: 'write',
execute: async ({ label }, ctx) => {
const list = await loadSessions(ctx.kv);
if (running(list)) throw new Error('已经有一个正在进行的专注,请先结束它');
const session = { id: Date.now().toString(36), label: label ?? '', startedAt: Date.now(), endedAt: null };
await saveSessions(ctx.kv, [...list, session]);
return { started: true, label: session.label };
},
}),
stop: defineTool({
title: '结束专注',
description: 'Stop the running focus session. Returns its length in minutes.',
input: z.object({}),
effect: 'write',
execute: async (_input, ctx) => {
const list = await loadSessions(ctx.kv);
const current = running(list);
if (!current) return { stopped: false, reason: 'nothing running' };
const now = Date.now();
await saveSessions(ctx.kv, list.map((s) => (s.id === current.id ? { ...s, endedAt: now } : s)));
return { stopped: true, minutes: Math.round((now - current.startedAt) / 60_000) };
},
}),
today: defineTool({
title: '今日专注',
description: 'Minutes focused today (device-local day), including the running session, and the running session if any.',
input: z.object({}),
effect: 'read',
execute: async (_input, ctx) => {
const list = await loadSessions(ctx.kv);
const current = running(list);
return { minutes: minutesOn(list, Date.now()), running: current ? { label: current.label, startedAt: current.startedAt } : null };
},
}),
},
});
```
`definePlugin` takes the whole plugin as one object. Each part has this purpose:
| Field | Purpose |
| --- | --- |
| `id` | Registry id, `^[a-z0-9][a-z0-9-]{1,40}$`. Tool ids become `focus.start`, model names `focus__start`. |
| `name`, `description`, tool `title`s | Chinese, shown to users. English in `i18n.en`. |
| `instructions` | Added to the assistant's system prompt and sent with every request, so keep it short. Refer to tools by model name. |
| `dataSources` | Read tools suitable for widgets and checks, with a cache lifetime. |
| `contributes.screens` | Mounted at `/p/focus/main` (or `/m/focus/` for `presentation: 'modal'`). |
| `contributes.settings` | A row in Settings that opens a screen. |
| `contributes.widgets` | A native dashboard widget. The assistant reads its `description`. |
| `contributes.commands` | A `/focus` command in the chat composer (here a local action). |
| `tools` | The API: one effect each, zod inputs, results as data. |
## 4. UI
Screens and widgets get the app's components through the `host` prop. They never import them from
the app. This keeps the plugin independent of the app's code, so it keeps working when the app
changes. The screen, the widget and the string table look like this:
```tsx title="plugins/focus/src/screen.tsx"
import type { PluginScreenProps } from '@pku-harness/sdk';
import { strings } from './strings';
type Today = { minutes: number; running: { label: string; startedAt: number } | null };
/** Start and stop focus sessions. Calls the plugin's own tools, as the user. */
export function FocusScreen({ host, invoke }: PluginScreenProps) {
const { Screen, Stat, Button, Column, Text } = host.components;
const s = strings[host.useLocale()];
const data = host.useToolData('focus.today', {}, { ttlMinutes: 1 });
const active = data.value?.running ?? null;
const toggle = async () => {
host.haptics.tap();
await invoke(active ? 'focus.stop' : 'focus.start', {});
await data.refresh();
};
return (
{active ? s.running(active.label) : s.idle}
);
}
```
```tsx title="plugins/focus/src/widget.tsx"
import type { PluginWidgetProps } from '@pku-harness/sdk';
import { View } from 'react-native';
import { strings } from './strings';
type Today = { minutes: number; running: { label: string; startedAt: number } | null };
/** Today's focus minutes; refreshed by the host (never poll from a widget). */
export function FocusWidget({ host, size }: PluginWidgetProps) {
const { Text, Skeleton } = host.components;
const t = host.useTheme();
const s = strings[host.useLocale()];
const data = host.useToolData('focus.today', {}, { ttlMinutes: 1 });
if (data.status === 'loading') return ;
if (!data.value) return {data.error ?? ''};
return (
{size === 'sm' ? (
{s.today}
) : null}
{s.minutes(data.value.minutes)}
{data.value.running ? s.running(data.value.running.label) : s.idle}
);
}
```
```ts title="plugins/focus/src/strings.ts"
import { defineStrings } from '@pku-harness/sdk';
type Strings = {
today: string;
minutes: (n: number) => string;
start: string;
stop: string;
running: (label: string) => string;
idle: string;
};
export const strings = defineStrings({
'zh-Hans': {
today: '今日专注',
minutes: (n) => `${n} 分钟`,
start: '开始专注',
stop: '结束',
running: (label) => (label ? `正在专注:${label}` : '正在专注'),
idle: '还没有开始',
},
en: {
today: 'Focus today',
minutes: (n) => (n === 1 ? '1 min' : `${n} min`),
start: 'Start focusing',
stop: 'Stop',
running: (label) => (label ? `Focusing on ${label}` : 'Focusing'),
idle: 'Not started yet',
},
});
```
The screen calls the plugin's own tools with `invoke`. `invoke` runs a tool **as the user**
(`actor: 'user'`). The input is validated and permissions apply as usual, but no approval prompt is
shown, because the user has just tapped the button. After a write, refresh any live query you are
showing, as `toggle` does with `data.refresh()`.
## 5. Tests
The tests cover `minutesOn`, the pure function from step 2. Because it takes `now` as an argument,
each test can fix the time.
```ts title="plugins/focus/src/store.test.ts"
import { describe, expect, test } from 'bun:test';
import { minutesOn, type Session } from './store';
const at = (h: number, m = 0) => new Date(2026, 9, 21, h, m).getTime();
describe('minutesOn', () => {
test('sums finished and running sessions of the day', () => {
const list: Session[] = [
{ id: 'a', label: '高数', startedAt: at(8), endedAt: at(8, 50) },
{ id: 'b', label: '英语', startedAt: at(10), endedAt: null },
];
expect(minutesOn(list, at(10, 30))).toBe(80);
});
test('cuts a session that started yesterday at midnight', () => {
const list: Session[] = [{ id: 'c', label: '', startedAt: at(0) - 30 * 60_000, endedAt: at(0, 20) }];
expect(minutesOn(list, at(12))).toBe(20);
});
});
```
## 6. Register the plugin
The app loads only the plugins listed in `plugins/index.ts`. Add yours to the list. The order of the
list is also the order in Settings and in the system prompt:
```ts title="plugins/index.ts"
import { focusPlugin } from './focus/src';
export const builtinPlugins: PluginDef[] = [
// …
focusPlugin,
];
```
Then add an entry to `registry/registry.json`, the list the marketplace shows. Choose an icon that
no other entry uses:
```json
{
"id": "focus",
"name": "专注",
"description": "记录专注时段,查看今天专注了多久。",
"author": "your-github-name",
"source": "https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/focus",
"kind": "builtin",
"icon": "clock",
"requires": [],
"scopes": [],
"tags": []
}
```
Finally, run the same checks as continuous integration (CI):
```bash
bun test # your tests + the i18n coverage test (plugins/i18n.test.ts)
bunx tsc --noEmit -p . # typecheck SDK + plugins
bun scripts/validate-registry.ts # schema, unique ids and icons, built-ins exist
```
## Run it in the app
The app includes `pku-harness-plugins` as the git submodule `plugins/`, and loads
`@pku-harness/sdk` and the bundled plugins from there. To see your plugin in the app, you therefore
work inside that submodule.
### Clone the app with its submodule
```bash
git clone --recurse-submodules https://github.com/pkuinfo/pku-harness.git
cd pku-harness
bun install
```
### Work in `plugins/`
Create `plugins/plugins/focus/`, which is the `plugins/` folder inside the submodule. Register the
plugin in `plugins/plugins/index.ts`, then run the app:
```bash
bun run android # dev build on an emulator or device
# or, with a dev build installed:
bunx expo start --dev-client
```
Metro, the JavaScript bundler of React Native, reloads the app when you save a file. To push your
branch, point the submodule at your fork (`git -C plugins remote add …`). Open the pull request
against `pku-harness-plugins`. The app repository records the new submodule commit later.
### Try it
There are four ways in. Open Settings → Focus, add the widget from the dashboard's widget gallery,
type `/focus` in the chat, or ask the assistant "开始专注高数作业".
If you have no API key for a model, use the [mock model](/en/docs/build/testing#mock-model). On a
device, a deep link that works only in development builds runs a tool directly:
`pkuharness://dev?tool=focus.today&input={}`.
## Rules
- **MUST** import only from `@pku-harness/sdk`, `zod`, `react` and `react-native`, plus your own
files and `plugins/shared/` (code shared by bundled plugins, e.g. `campus-card.ts`). No app
imports, and no imports from another plugin's folder.
- **MUST** follow the [tool rules](/en/docs/build/tools), [i18n rules](/en/docs/build/i18n) and
[capability rules](/en/docs/concepts/capabilities).
- **MUST** load widget and screen data with `host.useToolData`. Don't poll.
- **SHOULD** keep `instructions` and `context` short, because they cost tokens on every request.
# Declarative plugins (PLUGIN.md) (/en/docs/build/declarative-plugins)
> The plugin language. A plugin is a folder with a PLUGIN.md manifest, instructions for the assistant, tools written as YAML steps, and generative-UI widgets. The app interprets it and installs it from a link, with no code to build and no app release.
This page describes the plugin language: how a plugin folder is laid out, what goes in the
manifest, how tools are written as steps and expressions, and how plugins are installed and
updated. If you haven't written a plugin yet, do the [quickstart](/en/docs/build/quickstart)
first.
The language is specified in
[`docs/PLUGIN-LANGUAGE.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/PLUGIN-LANGUAGE.md)
(spec version 1) and implemented in the `lang/` folder of `@pku-harness/sdk`, which holds the
schemas, the interpreter, the host abilities and the loader. This page summarises the spec. When
the two disagree, the app follows the SDK's
[`schema.ts`](https://github.com/pkuinfo/pku-harness-plugins/blob/main/packages/sdk/src/lang/schema.ts)
and [`abilities.ts`](https://github.com/pkuinfo/pku-harness-plugins/blob/main/packages/sdk/src/lang/abilities.ts).
A declarative plugin is a folder of text files that you can share as a link. It plays the same
role for PKU harness that a `SKILL.md` folder plays for an AI agent. The app reads the folder and
runs the steps it declares in a small, safe interpreter. The app never downloads or runs plugin
code. As a result, publishing a new plugin or an update needs neither an app release nor a server.
A declarative plugin suits any job that consists of a few requests to campus or public websites
plus some extraction. Examples are free classrooms, canteen crowds and opening hours, shuttle
timetables, grades, campus events and notices.
Some jobs can't be expressed safely in the language, such as reading an SMS code or adding a
to-do. The app provides these as _host abilities_: features built into the app that any plugin may
call once it declares them. If you run your own server, write a
[remote plugin](/en/docs/build/remote-plugin) instead. You can also
[write a plugin with the AI](/en/docs/build/ai-authoring) inside the app.
## Official documents
The official campus integrations in
[`pku-harness-plugins/plugins`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins)
are plugin documents, and each comes with recorded tests. They make good worked examples. Each one
shows a different part of the language:
- [`classrooms`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/classrooms)
sends public requests in parallel with `each`, defines aliases in `constants`, and has a
dashboard widget.
- [`campus-events`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/campus-events)
reads Treehole (树洞) calendars through `verify.treehole`.
- [`grades`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/grades) handles a
second factor, by SMS or by the authenticator app (手机令牌) of IAAA, PKU's central sign-in
system, with `verify.treehole` and
`verify.treeholeSendCode`.
- [`canteen`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/canteen) uses a
`shared:` file, caches results in key-value (KV) storage with a fallback to stale data, and reads
HTML tables with merged cells.
- [`shuttle`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/shuttle) uses no
network at all. Its timetables are written into the plugin as data, and its widget refreshes
every minute.
- [`notices`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/notices) reaches a
wildcard host (`*.pku.edu.cn`) and WeChat articles. It keeps a table of sources in `shared:`,
caches each page in KV with a stale fallback, marks tools that return third-party text with
`untrusted: true`, and adds a slash command.
The app bundles these documents and updates them from their GitHub links, like any other plugin.
Fixing a parser in one of them therefore needs no app release.
## Layout
```
classrooms/
PLUGIN.md # manifest (YAML frontmatter) + instructions for the agent (Markdown)
tools/free.yaml # one tool per file (or inline in the frontmatter)
shared.yaml # optional: constants and functions every tool sees
widgets/overview.json # dashboard widget: generative-UI spec + bindings
screens/…json # full-page generative-UI screens
tests/free.yaml # recorded fixtures for the CLI and CI; never fetched by the app
README.md, assets/ # for humans and the marketplace; ignored by the app
```
- Each folder holds one plugin, and a repository can hold many. In the marketplace, a plugin is
identified as `owner/repo/path`.
- The app loads only the files **named in the frontmatter**. Paths are relative and may not contain
`..`. Because the app never lists folders, any static host works: GitHub, a gist or your own
server.
- Files are UTF-8 text. The files the app loads may add up to at most **512 KB**.
## The manifest
The manifest is the YAML frontmatter at the top of `PLUGIN.md`. Here is the manifest of a simplified
`classrooms` plugin:
```md title="classrooms/PLUGIN.md"
---
spec: 1
id: classrooms
name: 空闲教室
description: 查询各教学楼今天、明天、后天的空闲教室。
version: 1.2.0
author: pkuinfo
icon: location
network: [portal.pku.edu.cn]
tools:
free: tools/free.yaml
widgets:
overview: widgets/overview.json
dataSources:
overview: { tool: free, args: { day: today }, ttl: 30m }
i18n:
en:
name: Free classrooms
description: Free classrooms in each teaching building.
tools: { free: { title: Free classrooms, summary: Find free classrooms in a building } }
---
Use `free` when the user asks where to study or which rooms are free. Periods are 1–12
(1 = 08:00). Answer with the building and room, and the free period ranges.
```
Six fields are required: `spec: 1`, `id`, `name`, `description` (4 to 200 characters), `version`
(a semantic version such as `1.2.0`) and `author`.
### What the plugin declares
Four fields matter most, because students see them on the review sheet before they install.
`campus` lists the campus services the plugin may call **with the user's signed-in session**. The
names are `card`, `its`, `treehole`, `blackboard`, `elective`, `cwfw`, `cwpay`, `bdkj`, `library`,
`library-space` and others. The app signs these requests itself, so the plugin never sees a
password, a cookie or a token.
`network` lists the public hosts the plugin may call, written as `example.com` or
`*.example.com`. Only HTTPS is allowed.
`abilities` lists the [host abilities](#host-abilities) the plugin uses: `verify`, `otp`, `todos`,
`timetable`, `scheduler`, `browser`, `campusPage`, `sms`, `files`, `crypto` and `cloud`.
`tools` names each tool's file, and each tool declares its own `effect`: `read`, `write`, `money` or
`public`.
### Optional fields
- `shared` names a YAML file of `constants` and `functions` that every tool can use. When a tool
defines the same name itself, the tool's own definition wins.
- `dataSources`, `widgets` and `screens` add live data and generative-UI views.
- `settings` and `commands` add Settings rows and slash commands.
- `defaultModes` sets the starting permission mode for each effect.
- `i18n` holds the English text. It can translate the name, the description, the instructions,
and each tool's `title` and `summary` (`i18n.en.tools.`).
- `remote: { mcp, auth }` describes a remote MCP server with a `PLUGIN.md`.
- `cloud` declares the plugin's use of PKU harness Cloud, including `cloud.disclosure`, and
`server` declares server functions ([below](#server-functions)).
You don't need to write `requires`. It is derived from `campus`, `network`, `cloud` and `server`.
The [manifest reference](/en/docs/marketplace/manifest) explains each field and lists what the
marketplace checks.
### The instructions
The **Markdown body** is sent to the model as the plugin's instructions. Keep it short: say which
tool to use for which question, and what the numbers mean. In the body, refer to a tool as
`__`, for example `classrooms__free`, because that is the name the model sees.
## Tools
A tool is a piece of data with these parts:
- `title`: the tool's name, shown to users. At most 60 characters.
- `summary` (optional): one line for people, at most 80 characters. The install review sheet and the
marketplace's plugin page show it under the title. Translate it in
`i18n.en.tools..summary`; without that, the app shows English users
no summary line.
- `description`: for the model only, never shown to users.
- `effect`: `read`, `write`, `money` or `public`, declared honestly.
- `input`: the typed input fields.
- `constants` and `functions` (optional): values and helper expressions for this tool.
- `steps`: the requests and transformations, run in order.
- `return`: an expression that builds the result.
Two flags change how the app treats a tool. `untrusted: true` marks a tool whose results carry
third-party text, such as posts or articles. `userOnly: true` means only a person's tap can run the
tool; the assistant can't.
```yaml title="tools/free.yaml (abridged)"
title: 空闲教室
summary: 查某栋楼哪些教室空着
description: Free rooms in one building (or all) for today, tomorrow or the day after.
effect: read
input:
building: { type: string, optional: true, trim: true, description: 'e.g. 二教, 理教' }
day: { enum: [today, tomorrow, dayAfter], default: today }
constants:
DAY_QUERY: { today: 今天, tomorrow: 明天, dayAfter: 后天 }
steps:
- let: page
request:
url: https://portal.pku.edu.cn/publicQuery/classroomQuery/retrClassRoomFree.do
query: { buildingName: '${input.building ?? "二教"}', time: '${DAY_QUERY[input.day]}' }
headers: { referer: 'https://portal.pku.edu.cn/publicQuery/' }
as: json
- check: page.success
error: 空闲教室查询未成功
return: "page.rows.map(r => ({ room: r.room, seats: num(r.cap, 0) }))"
```
Each **input field** has the form
`{ type, enum?, default?, optional?, description?, min?, max?, pattern?, trim?, items? }`.
`type` is one of `string`, `number`, `integer`, `boolean`, `array` and `object`. `trim: true`
removes spaces at both ends of a string before it is validated. The app checks the whole input
before any step runs, so the steps can rely on it.
### Steps
Each step does one thing. A step may bind its result to a name with `let: name`. Later steps and
`return` can use every bound name, as well as `input`, `constants`, `functions` and `now`.
| Step | What it does |
| --- | --- |
| `{ let, value: expr }` | Binds the value of an expression. |
| `{ let, request, as? }` | Sends one HTTP request. |
| `{ let, each, concurrency?, request \| steps \| value }` | Runs once for each item of a list. |
| `{ let, paginate: { request, next, items, max } }` | Repeats a request page by page. |
| `{ let, extract: { from, each, fields }, limit? }` | Picks items out of HTML. |
| `{ if, then, else? }` | Branches. |
| `{ check, error }` | Fails with a message when a condition is false. |
| `{ try, catch }` | Recovers from an error; `error` is in scope in `catch`. |
| `{ let?, kv: get \| set \| delete, key, value?, ttl? }` | Reads or writes the plugin's own storage. |
| `{ let, cache: { key, ttl, staleOnError? }, steps }` | Caches the value of the inner steps in KV. |
| `{ let?, call: , input }` | Calls another tool of the same plugin. |
| `{ let?, ability: ., args }` | Calls a [host ability](#host-abilities). |
A few steps need more explanation.
A **request** either names a campus service with `campus: ` and a path on that service, or
gives a public `https` URL on one of the `network` hosts. `as` says how to read the reply: `json`,
`text`, `html` or `response`. With `response` you get `{ status, url, headers, text }` for any
status code. The other forms fail when the status is not 2xx.
**`each`** runs over a list of at most 50 items, with 1 to 4 running at the same time. Inside it,
`item` and `index` are in scope. **`paginate`** repeats the request while `next` is not null, for at
most 30 pages.
**`extract`** selects elements from HTML with a CSS selector. Each field can pass its value through
pipes such as `text`, `attr href`, `url`, `num`, `date` and `match `.
Every run has fixed limits: 200 steps, 60 requests with at most 4 at the same time, 60 seconds,
2 MB per response, a 1 MB result, 100,000 expression operations, and 256 KB of KV storage per
plugin. The language has no unbounded loops, no `eval` and no JavaScript.
### Expressions
Some fields hold an _expression_: `value`, `each`, `if`, `check`, `return`, `next`, `items`, `from`,
the input of `call`, the args of `ability`, and the bodies of `functions`. An expression is a single
expression in a small, safe subset of JavaScript. You can use literals, `.` `?.` `[]`, arithmetic,
strict `==` and `!=`, `&& || ?? !`, `a ? b : c`, and arrow functions as arguments. You can't assign,
use `new` or reach any globals. Using a name that was never bound is an error.
Every other string is a _template_. In a template, `${expr}` is replaced by the value of the
expression. When the whole template is exactly one `${expr}`, the value keeps its type instead of
becoming a string.
The standard library covers lists (`map`, `filter`, `sortBy`, `groupBy`, `sum`, `reduce` and so
on), objects, strings, numbers, dates in Beijing time, URLs, JSON and HTML (`html`, `select`,
`first`, `tableRows`, `htmlText` and so on). The full list is in §6.2 of the spec, and inside the
app in `plugins.reference`.
A few details often catch people out:
- **Regular expressions are strings.** Each one is compiled once and has a length limit. For
case-insensitive and similar matching, start the pattern with inline flags, for example
`'(?i)^yanyuan'`. The flags are `i`, `m`, `s` and `u`.
- **String escapes.** `\\`, `\'`, `\"`, `\n`, `\t` and `\r` work as in JavaScript. Any other escape
**keeps its backslash**, so `'\d+'` is the regex `\d+`. This differs from JavaScript on purpose,
so that patterns survive YAML unchanged. Still, write regexes in single-quoted YAML strings or in
`|` blocks.
- **Comments.** `// …` and `/* … */` work inside expressions.
- **Merged table cells.** `tableRows(node, { header: true })` returns one object per row, keyed by
the header labels, with rowspan and colspan expanded. `tableRows(node, { cells: true })` returns
grid rows of `{ text, row, col, rowspan, colspan, id }`. Every slot covered by the same merged
cell has the same `id`.
Quote an expression that contains `: ` or starts with `{`, `[`, `'` or `"`, or write it as a
block scalar (`return: |`). Without quotes, YAML reads it as a map or a list. The validator points
to the line when this happens.
### Results
Return plain JSON, never instructions. The app recognises a few result shapes:
- `{ status: 'verification_required', kind, methods, message }` means the user must supply a code.
- `{ status: 'needs_input', fields, message }` means the tool needs more input from the user.
- `{ items, hasMore, truncated }` is the shape for lists.
## Host abilities
A host ability is a feature of the app that a plugin may use once it lists the ability's group in
`abilities`. Each operation has a fixed effect, and the tool that calls it must declare **at least**
that effect. For example, a `read` tool can't call `todos.add`, which is a `write` operation. Any
name not in the list below is refused.
Plugins can't add abilities of their own. New abilities are added to the app itself, where they are
reviewed once, and from then on every plugin can use them.
| Ability | Operations (effect) |
| --- | --- |
| `verify` | `treehole { request, code?, via?: sms \| otp, kind?: login \| course }` (read), `treeholeSendCode { kind? }` (write) |
| `otp` | `code` (read): the current code of the IAAA authenticator (手机令牌), or null |
| `todos` | `list` (read); `add`, `update`, `setDone`, `remove` (write) |
| `timetable` | `get` (read); `upsert`, `remove` (write) |
| `scheduler` | `list` (read); `create`, `update`, `remove` (write) |
| `browser` | `open { url }` (read): opens an https URL outside the app |
| `campusPage` | `open { service }` (read): opens a campus site, signed in |
| `sms` | `awaitCode` (read): fills in an SMS code in one tap, after the user allows reading one SMS |
| `files` | `pick { types?, multiple? }` (read): lets the user pick files |
| `crypto` | `sha256 { text }` (read) |
| `cloud` | `call { fn, input }` (read): calls one of this plugin's [server functions](#server-functions) |
A step that calls an ability looks like this:
```yaml title="A tool step with an ability"
- let: data
ability: verify.treehole
args: "{ request: { url: '/chapi/api/course/score_v2' }, code: input.code, via: input.method ?? 'sms' }"
```
### Second factors (`verify`)
Treehole sometimes asks for an extra check before it answers: an SMS code at login, or a token
before it returns course data. Handling this takes several requests in the right order.
`verify.treehole` wraps one Treehole request and does that work, so your plugin doesn't have to.
It behaves as follows:
- If you pass a `code`, it redeems the code first and then sends the request. `via` says whether
the code came by `sms` or is a code from the authenticator app (`otp`). When no check is pending,
`kind` says which check the code is for: `login` or `course`.
- If the course token is needed and you passed no code, it tries a code from the bound
authenticator on its own.
- Otherwise it returns `{ status: 'verification_required', kind, methods, message, next }`, and the
assistant asks the user for a code.
- A Treehole reply without `success: true` counts as a failure, and the tool fails with Treehole's
message.
`verify.treeholeSendCode { kind? }` sends the SMS for the check that is pending. When no check is
pending, `kind` picks one. If Treehole says the previous code hasn't expired yet, that also counts as
sent.
## Server functions
The `server:` key, `{ fn }` bindings and the `cloud.call` ability exist in the SDK, but plugin
server functions are **turned off on the official server for now**. Keep plugins on the phone, or
use your own backend as a [remote plugin](/en/docs/build/remote-plugin).
A `server:` section declares functions written with the same steps and expressions. A PKU harness
Cloud server runs them, with a set of abilities of its own: server-side KV storage,
`crypto.sha256`, `hmacSha256` and `randomId`, and HTTPS requests to the hosts in `server.network`
using secrets set by the server's operator. Server functions never get campus access.
A plugin with `server:` must say in `cloud.disclosure` what data leaves the device. The design and
its status are in
[`docs/CLOUD-FUNCTIONS.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/CLOUD-FUNCTIONS.md),
which is on hold. See also [Cloud → For plugin authors](/en/docs/cloud/plugin-guide#server-functions).
## Install links
Students can install a plugin in four ways: paste a link in the app (设置 → 插件 → add), scan a QR
code, tap *Open in app* in the [marketplace](/en/marketplace), or ask the assistant, which uses
`plugins.install`. The app accepts these link forms:
| Form | Example |
|---|---|
| GitHub folder | `https://github.com///tree//` |
| GitHub repository root | `https://github.com//` (PLUGIN.md at the root) |
| GitHub file | `https://github.com///blob///PLUGIN.md` |
| Any HTTPS URL of a `PLUGIN.md` | a raw gist URL, your own server |
| Deep link | `pkuharness://plugin?src=` |
Before installing, the app fetches and validates every file. It then shows the **review sheet**,
which tells the student three things:
- Whether the plugin is **Official** or **Community**. Official means bundled with the app or linked
from `github.com/pkuinfo/pku-harness-plugins`. Everything else is community.
- What the plugin can reach: **campus services**, which it calls with the user's signed-in session
without ever seeing the password; **websites**, which are its `network` hosts; and **app
features**, which are its abilities.
- What the plugin can do: every tool, with its summary and its effect.
A community plugin starts with its `write`, `money` and `public` tools set to *ask*. Its results
reach the AI as untrusted data and are never followed as instructions. Nothing is installed until the
user taps *Install*.
## Updates
The app checks an installed plugin's link again when it starts (at most once a day) and when the
user opens the plugin's page. What happens next depends on what changed:
- If the content hash is the same, nothing happens.
- If the content changed but the declarations didn't, the update is applied, with a note such as
"updated to 1.3.0".
- If the declarations got wider, for example a new campus service, host or ability, or a stronger
effect, the update waits until the user reviews and accepts it.
Users can turn off automatic updates or roll back to the previous version. For you as the author,
publishing an update is just a `git push`.
## Test
```bash
bun run plugin validate path/to/my-plugin # every check the app runs, with file and step
bun run plugin test path/to/my-plugin # replay tests/*.yaml (recorded responses)
bun run plugin run path/to/my-plugin '{"k":"v"}' # live, public hosts only
```
Run these commands from a checkout of `pku-harness-plugins`. Campus requests need the app, so record
their responses in `tests/*.yaml` (see the [quickstart](/en/docs/build/quickstart#record-a-test)).
In the plugins repository, continuous integration (CI) replays every document's tests through
`plugins/documents.test.ts`.
## Publish
Push the folder to a public GitHub repository, then submit the repository on the
[marketplace](/en/marketplace/submit). The marketplace finds every `PLUGIN.md`, checks the paths and
declarations, and shows exactly what students will see. See
[Publishing](/en/docs/marketplace/publishing) and the [review rules](/en/docs/marketplace/review).
To make a plugin official, open a pull request that adds its folder under `plugins/` in
`pku-harness-plugins`, together with recorded tests.
# Languages (i18n) (/en/docs/build/i18n)
> How a bundled plugin provides Chinese and English. Manifest strings get English versions in `i18n.en`, screen and widget text goes in a `defineStrings` table, dates go through the SDK formatters, and text for the model stays untranslated.
The app is available in Simplified Chinese (`zh-Hans`) and English (`en`). Chinese is the source
language: you write every string in Chinese first and then add the English version. Users choose
跟随系统 (follow the system), 简体中文 or English in Settings.
Every bundled plugin **MUST** be complete in both languages. The test `plugins/i18n.test.ts` in the
plugins repository checks each plugin and fails when an English string is missing. This page covers
bundled plugins. A plugin document puts all its English text in the `i18n.en` section of its
manifest (see [Declarative plugins](/en/docs/build/declarative-plugins#the-manifest)).
## What gets translated
Text that people read is translated. Text that only the model reads, such as tool descriptions and
instructions, is written once and not translated.
| Text | Where | Translate? |
| --- | --- | --- |
| Plugin `name`, `description` | `definePlugin` (Chinese) + `i18n.en` | Yes |
| Tool `title`s | `tools..title` + `i18n.en.tools..title` | Yes |
| Tool `summary` (plugin documents only) | `summary` in the tool file + `i18n.en.tools..summary` | Yes |
| Widget titles and descriptions | `contributes.widgets` + `i18n.en.widgets` | Yes |
| Screen titles | `contributes.screens` + `i18n.en.screens` | Yes |
| Settings rows | `contributes.settings` + `i18n.en.settings.` | Yes |
| Commands | `contributes.commands` + `i18n.en.commands.` (`description`, `argHint`, `aliases`, `prompt`) | Yes |
| Data source titles | `dataSources` + `i18n.en.dataSources` | Yes |
| Provider names, hints, model names | `providers` + `i18n.en.providers` | Yes |
| Screen and widget text | `src/strings.ts` (`defineStrings`) | Yes |
| Tool `description`, `instructions`, `context`, tool results | For the model | **No** |
## Manifest overlay
The Chinese strings stay where they are in `definePlugin`. The English versions go in `i18n.en`.
This example is from the `shuttle` plugin:
```ts
definePlugin({
id: 'shuttle',
name: '校车',
description: '燕园—新燕园(昌平)通勤班车和新燕园—地铁北安河快速公交时刻表,算出下一班车。',
i18n: {
en: {
name: 'Shuttle bus',
description: 'Timetables for the Yanyuan–Xinyanyuan (Changping) commuter shuttle and the Xinyanyuan–Bei’anhe metro express bus, with the next departure worked out for you.',
tools: { next: { title: 'Next shuttle' }, timetable: { title: 'Shuttle timetable' } },
widgets: { next: { title: 'Next shuttle', description: '…' } },
commands: { shuttle: { description: 'Next shuttle (works offline)', argHint: 'e.g. Yanyuan to Xinyanyuan', aliases: ['bus'] } },
dataSources: { next: { title: 'Next shuttle' } },
},
},
// …
});
```
At runtime, anything missing from `en` falls back to Chinese. The coverage test still fails, so fill
in everything.
## String tables
Text inside screens and widgets lives in a string table, one per plugin, in `src/strings.ts`. You
define its keys once as a type, then give a value for each language:
```ts title="src/strings.ts"
import { defineStrings } from '@pku-harness/sdk';
type Strings = {
none: string;
later: (list: string) => string;
};
export const strings = defineStrings({
'zh-Hans': { none: '今明两天没有班次', later: (list) => `之后 ${list}` },
en: { none: 'No buses today or tomorrow', later: (list) => `Then ${list}` },
});
```
```tsx
const s = strings[host.useLocale()];
{s.none}
```
In a component, pick the table for the user's language with `host.useLocale()`.
`defineStrings` type-checks `en` against `zh-Hans`, so both need the same keys with the same value
types. A string with parameters is a function. Never build a sentence by joining translated
fragments, because word order differs between the two languages.
## Dates and numbers
Hermes, the JavaScript engine the app runs on, ignores `Intl` locales. `toLocaleDateString` and
similar functions therefore give the wrong format. Use the SDK formatters instead. Most of them take
the locale `l` from `host.useLocale()`:
| Function | zh-Hans | en |
| --- | --- | --- |
| `formatClock(d)` | `21:04` | `21:04` |
| `formatWeekday(d, l)` | `周一` | `Mon` |
| `formatLocalDate(d, l)` | `10月18日` | `Oct 18` |
| `formatLocalDateTime(d, l)` | `10月18日 21:04` | `Oct 18, 21:04` |
| `formatRelative(ms, l)` | `刚刚`, `3 分钟前`, `昨天 21:04` | `Just now`, `3 min ago`, `Yesterday 21:04` |
| `formatDay(d, l)` | `今天`, `明天`, `周三` | `Today`, `Tomorrow`, `Wed` |
| `formatCountdown(ms, l)` | `2小时10分` | `2h 10m` |
| `plural(n, one)` | — | `1 book`, `3 books` |
Details in [SDK → format](/en/docs/sdk/format).
## English copy rules
The coverage test also checks how English strings are written:
- No Chinese except inside a parenthesised gloss or quotes. `Treehole (树洞)` is fine.
- Curly apostrophes (`’`), not straight ones (`'`).
- No exclamation marks.
- Keep it short and in sentence case, which capitalises only the first word and proper nouns.
Campus proper nouns use the official English name or pinyin: Peking University, Treehole (树洞),
Course (教学网 / Blackboard), Campus Card.
Chinese text follows the same idea: keep it short and use no exclamation marks.
# Build a plugin (/en/docs/build)
> How to choose between a PLUGIN.md document, a remote plugin and a bundled TypeScript plugin, which repositories to clone, and the order of work from your first tool to a marketplace listing.
A plugin connects PKU harness to one campus service, or adds one feature to the app. Each plugin
offers a set of _tools_: small, typed operations such as "list free classrooms" or "show my card
balance". The AI assistant and the user's own taps both call the same tools.
Writing a plugin therefore comes down to three jobs. You find out how the service answers
requests, you wrap those requests in tools, and, where it helps, you add a widget or a screen.
This section assumes you can read YAML or TypeScript and use a terminal. You don't need to know
the app's internals.
## Choose the kind
There are three kinds of plugin. Most people should write the first one.
A **plugin document** is a folder with a `PLUGIN.md` file. Its tools are written as YAML steps
with small, safe expressions, and the app interprets them on the phone. You publish it with
`git push`, and students install and update it from a link. Use it for most campus
integrations: requests to campus or public websites, extraction of the data you need, and the
user's campus session when a service requires sign-in. Start with the
[quickstart](/en/docs/build/quickstart), then read [Declarative plugins](/en/docs/build/declarative-plugins).
You can also [write it with the AI](/en/docs/build/ai-authoring) inside the app.
A **remote plugin** is a server you run yourself. It speaks the Model Context Protocol (MCP), a
standard way for AI apps to call tools over HTTP, using the Streamable HTTP transport. You can
write it in any language. Updates go out when you deploy the server; no app release is involved.
Use it for your own service, for heavy work, or for anything that needs general-purpose code.
Start from [`templates/remote-plugin`](/en/docs/build/remote-plugin).
A **bundled plugin** is a TypeScript module, written with zod and React Native, that ships inside
the app. It reaches users only with app releases, so open an issue before you start. Use it for
the app's own features (dashboard, schedule, scheduler, memory, model providers and so on) and for
integrations the plugin language can't express yet. Start with
[`definePlugin`](/en/docs/build/bundled-plugin).
| | Plugin document | Remote plugin | Bundled plugin |
| --- | --- | --- | --- |
| Written in | YAML steps and expressions | Any language (MCP) | TypeScript |
| Runs on | The phone, interpreted by the app | Your server | The phone, inside the app |
| How updates reach users | `git push` | Deploy your server | App release |
The official campus integrations classrooms, campus-events, grades, canteen, shuttle and notices
are already plugin documents. The other official integrations are still TypeScript, and we are
porting them one at a time.
## The order of work
### Read the concepts
Four pages set the rules every plugin follows: [Plugins](/en/docs/concepts/plugins),
[Tools and effects](/en/docs/concepts/tools-and-effects), [Permissions](/en/docs/concepts/permissions)
and [Security](/en/docs/concepts/security). Read them first, because the review checks your plugin
against them.
### Find the protocol
You need to know which addresses the service uses and what it sends back. If
[`pkucli`](https://github.com/pkuinfo/pkucli) already talks to the service, port its code and
credit the file you used. [Porting from pkucli](/en/docs/build/porting-from-pkucli) explains how.
### Write the tools
Write one tool per operation. Declare its effect honestly, put limits on every input, and write the
description for the model. For a plugin document, see
[Declarative plugins](/en/docs/build/declarative-plugins). For code, see
[Tools and schemas](/en/docs/build/tools).
### Add UI
A plugin document can add generative-UI widgets and screens (described as JSON and drawn by the
app), Settings rows and slash commands. A bundled plugin can also add native screens. See
[UI contributions](/en/docs/build/ui) and [Languages](/en/docs/build/i18n).
### Test
Each kind has its own tools. For plugin documents, `bun run plugin validate|test` checks the files
and replays recorded responses. For TypeScript logic, use `bun test`. To exercise the assistant's
loop without an API key, use the mock model. To try a remote plugin, use the demo remote plugin.
[Testing](/en/docs/build/testing) covers all four.
### Publish
Share the install link, then list the plugin in the marketplace. See
[Publishing](/en/docs/build/publishing), and in the marketplace section the
[manifest reference](/en/docs/marketplace/manifest) and the [submit flow](/en/docs/marketplace/publishing).
## Repositories you need
Start with [`pkuinfo/pku-harness-plugins`](https://github.com/pkuinfo/pku-harness-plugins). It
holds the SDK and the plugin language, the author command-line tool (`bun run plugin`), the
official plugin documents and bundled plugins, the marketplace registry, and the remote-plugin
template and examples.
You need [`pkuinfo/pku-harness`](https://github.com/pkuinfo/pku-harness), the app itself, only to
run a bundled plugin on a phone or emulator. A plugin document doesn't need it until you want to
try campus requests for real.
[`pkuinfo/pkucli`](https://github.com/pkuinfo/pkucli) is a set of Rust command-line tools for PKU
services. Use it as a reference for how each service's protocol works.
```bash
mkdir pku-info && cd pku-info
git clone https://github.com/pkuinfo/pku-harness-plugins.git # SDK, plugins, registry, template
git clone --recurse-submodules https://github.com/pkuinfo/pku-harness.git # only to run bundled plugins in the app
cd pku-harness-plugins && bun install && bun test
```
The SDK and the plugins are not published to npm. You use them straight from the GitHub
repository: work inside a checkout of `pku-harness-plugins`, whose bun workspace and
`tsconfig.json` resolve `@pku-harness/sdk` to `packages/sdk/src`. The app pulls in the same
repository as the git submodule `plugins/`. Remote plugins don't need the SDK at all.
## Ground rules (from CONTRIBUTING.md)
1. **Tools are the API.** Give each tool a clear Chinese `title`, which users see, and a
`description` written for the model that says when and how to use it. Validate every input:
with `input` fields in a plugin document, with zod in a bundled plugin, and with JSON Schema in
a remote plugin.
2. **Declare effects honestly.** A read-only tool sets `effect: read` (plugin documents and bundled
plugins) or `readOnlyHint: true` (MCP). A tool that changes state, spends money or posts publicly
must say so.
3. **Never ask for campus passwords.** Credentials stay on the user's phone.
4. **Return data, not instructions.** The model treats tool results as data.
5. **Declare what you reach.** In a plugin document, list `campus`, `network` and `abilities`;
`requires` is derived from them. In code, list `requires`. Don't depend on a shell or a file
system on the phone.
6. **Port campus protocols from pkucli** when it already implements them, and credit the source.
7. **Write both languages.** A plugin document has an `i18n.en` section. A bundled plugin needs an
English overlay for every user-facing manifest string, and a `src/strings.ts` table for the text
in its screens and widgets.
# Porting from pkucli (/en/docs/build/porting-from-pkucli)
> pkucli is the protocol reference for PKU services. How to port endpoints, login steps and quirks from its Rust crates into a plugin, leave sign-in to the app, and credit the source file.
[`pkuinfo/pkucli`](https://github.com/pkuinfo/pkucli) is a set of command-line tools for PKU web
services, written in Rust. Its authors have already worked out the hard parts of each service:
which endpoints exist, which sign-in steps the service needs, which headers and parameters it
requires, and how its HTML is structured.
When you write a plugin for a service that pkucli already supports, port pkucli's behaviour instead
of working it out again, and cite the source file in a comment. This page shows where to find things
in pkucli, which parts belong in the plugin and which stay in the app, and an example.
Most services sign in through IAAA, PKU's central sign-in system (iaaa.pku.edu.cn). After IAAA,
each service usually needs one or more single sign-on (SSO) steps of its own. We call each such step
a _login hop_.
## Where things are
| pkucli crate | Service | In PKU harness |
| --- | --- | --- |
| `common` | IAAA login (password, QR code), TOTP codes, captcha, sessions, credentials | The app (`src/core/campus`); **not** in plugins |
| `campuscard` | Campus card (bdcard) | `wallet` plugin, `card` campus service |
| `treehole` | Treehole (树洞) | `treehole` plugin, `treehole` service |
| `course` | Blackboard (教学网) | `courses` plugin, `blackboard` service |
| `elective` | Course selection | `elective` plugin, `elective` service |
| `cwfw`, `cwpay` | Finance, finance payments | `finance` plugin, `cwfw` / `cwpay` services |
| `bdkj` | Room booking | `room-booking` plugin, `bdkj` service |
| `portal` | Portal | The portal's SSO hop, inside the app |
| `claspider` | Course recordings (智云课堂) | Planned |
| `info-spider`, `info-auth` | WeChat articles | `notices` plugin reads single articles (cleaning ported from `info-spider`) |
TOTP (time-based one-time password) is the six-digit code an authenticator app shows. Inside a
crate, `api.rs` (or `client.rs`) holds the endpoints and response types, `login.rs` holds the
service's own login hops, and `commands.rs` holds the operations a user runs.
## What moves where
Each file of a pkucli crate maps to a place in PKU harness. Sign-in moves into the app, and
everything else moves into the plugin:
```
pkucli crate PKU harness
───────────────────────────── ──────────────────────────────────────────────
login.rs (IAAA → service SSO) app: a campus service adapter (harness-owned)
client.rs (headers, base URL) plugin: the `url`/`headers` of services.campus.request
api.rs (endpoints, types) plugin: src/api.ts + zod/TS types
commands.rs / display.rs plugin: tools (data, not formatted text)
```
- **Sign-in stays in the app.** A plugin never implements IAAA or a service's SSO hops, so it never
handles a password. Today the app signs in to `card`, `its`, `treehole`, `blackboard`, `bdkj`,
`cwfw`, `cwpay`, `elective`, `library` and `library-space`. If the service you need is not in that
list, open an issue in `pku-harness`. A new service needs a new adapter in
`src/core/campus/adapters/`.
- **Requests go through `services.campus.request(service, req)`.** Give paths relative to the
service's host. The app adds the cookies or tokens.
- **Public pages** that need no login can use plain `fetch` with `requires: ['network']`. Copy
pkucli's headers, such as a desktop user agent and `accept-language: zh-CN`, because some campus
servers reject requests without them.
- **Parse into data.** pkucli's `display.rs` formats output for a terminal. A tool returns structured
JSON instead, because its reader is the model or a screen.
## Example
Here is the campus card query in pkucli, written in Rust:
```rust title="crates/campuscard/src/api.rs"
/// 查询校园卡信息
pub async fn query_card(&self) -> Result {
let url = format!("{CARD_BASE}/berserker-app/ykt/tsm/queryCard?synAccessSource=h5");
let resp = self.http.get(&url).send().await.context("查询校园卡失败")?;
let api_resp: ApiResp = parse_response(resp).await?;
check_resp(api_resp)
}
```
The same request in a plugin, written in TypeScript. The comment at the top credits the pkucli file
and records two quirks: amounts are in fen (0.01 yuan), and every call carries
`synAccessSource=h5`.
```ts title="plugins/shared/campus-card.ts (abridged)"
/**
* Campus card (bdcard.pku.edu.cn) response handling, ported from pkucli
* crates/campuscard/src/api.rs. Amounts are in fen; every call carries synAccessSource=h5.
*/
import type { HostServices } from '@pku-harness/sdk';
type Services = Pick;
const get = (services: Services, url: string) => services.campus.request('card', { url });
export async function cardAccount(services: Services) {
const data = unwrap<{ card: CardInfo[] }>(await get(services, '/berserker-app/ykt/tsm/queryCard?synAccessSource=h5'));
const account = data.card[0]?.account;
if (!account) throw new Error('校园卡缺少账户信息');
return account;
}
```
## Second factors
Some services ask for an extra check, such as an SMS code. pkucli asks for the code in the
terminal, which a plugin can't do. In a plugin, a check at the service level becomes a tool result
such as `{ status: 'verification_required', methods: ['sms'], message }`, together with a tool that
sends or redeems the code. Treehole's SMS check (error `40002`) and its course token (error `40077`)
work this way. The plugin's screen can also offer `services.sms.awaitCode()`, which reads the code
from the SMS.
Second factors at the IAAA level are handled by the app, not by plugins.
## Rules
- **MUST** cite the pkucli file you ported from in a comment at the top of the module.
- **MUST** keep sign-in in the app. Plugins only make requests.
- **SHOULD** port each parser together with a test and a fixture made from a real, anonymised
response.
- **SHOULD** fix bugs in both places. If you find a quirk pkucli doesn't handle, open an issue or a
pull request in pkucli too.
# Publishing (/en/docs/build/publishing)
> How a remote or bundled plugin reaches users. Remote plugins are listed in the marketplace registry. Bundled plugins are merged into pku-harness-plugins and reach phones as a signed over-the-air update or with the next app release.
This page explains how a finished plugin reaches users. The steps differ for remote plugins and
bundled plugins. A plugin document is published by pushing its folder to GitHub; see
[Declarative plugins](/en/docs/build/declarative-plugins#publish) and the marketplace's
[submit flow](/en/docs/marketplace/publishing).
## Remote plugins
1. Deploy your MCP server over HTTPS ([Quickstart](/en/docs/build/remote-plugin#4-deploy)).
2. Make sure your MCP server's `name` matches the registry `id` you will use.
3. List the plugin in the marketplace. You can add a manifest to your repository and submit it on
this website ([manifest reference](/en/docs/marketplace/manifest),
[submitting](/en/docs/marketplace/publishing)). Or you can open a pull request that adds an
entry to `registry/registry.json` in
[`pku-harness-plugins`](https://github.com/pkuinfo/pku-harness-plugins). The
[review policy](/en/docs/marketplace/review) explains how entries are reviewed and how the
`verified` badge is given.
4. Users install the plugin from Settings → Plugins → Marketplace, or add your URL by hand.
Updates go out as soon as you deploy. The app picks up the new tool list the next time it refreshes
the plugin.
Keep tool names stable. Users' widgets and scheduled checks refer to tools by name, so renaming a
tool breaks them.
## Bundled plugins
1. Open an issue in `pku-harness-plugins` first. Say what the plugin does and why it has to be
bundled.
2. Send a pull request with three parts: the folder `plugins//`, the entry in `builtinPlugins`
in `plugins/index.ts`, and a registry entry with `"kind": "builtin"`.
3. Continuous integration (CI) and the reviewers run these checks:
```bash
bun test
bunx tsc --noEmit -p .
bun scripts/validate-registry.ts
```
4. After the merge, the app moves its `plugins/` submodule to the new commit. How the change then
reaches phones depends on what it touches. A change to JavaScript only is delivered as a signed
over-the-air (OTA) update, which the app downloads and applies on its next launch. A change
that needs native code waits for the next app release, which is a new APK.
Nothing is published to npm. The app and other developers use the SDK and plugins straight from the
GitHub repository.
## Renaming or merging
Users may have stored plugin ids, tool ids or widget keys in their widgets, checks or settings.
Never reuse such an id, and never rename one silently. Instead, use `PluginDef.legacy` to map the
old plugin ids, tool ids and widget keys to the new ones
([SDK → plugin](/en/docs/sdk/plugin#pluginlegacy)).
## Using PKU harness Cloud
PKU harness Cloud is our hosted service for features that need a server. If your plugin declares
`PluginDef.cloud`, copy its `cloud` declaration into the registry entry. The declaration has the
fields `uses`, `e2e` (whether stored data must be end-to-end encrypted), `disclosure` and, optionally,
`backend`.
The marketplace shows this declaration before install. `disclosure.zh` must say in plain words what
data leaves the device. See [Cloud → plugin guide](/en/docs/cloud/plugin-guide#6-registry-entry).
# Your first plugin (/en/docs/build/quickstart)
> Write a PLUGIN.md plugin with one tool, test it against a recorded response, and share it as a link. The example is a cut-down version of the free-classrooms plugin.
On this page you write a small plugin document with one tool. The tool answers one question: **how
many classrooms are free in each teaching building right now**. You then test it offline and share
it as a link.
The example is a cut-down version of a real plugin,
[`plugins/classrooms`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/classrooms)
in the plugins repository. It calls one public campus endpoint, so it needs no login.
Before you start, install [bun](https://bun.sh) and git. You don't need the app or a phone until
the last step.
For the full language, see [Declarative plugins](/en/docs/build/declarative-plugins). The
specification itself is
[`PLUGIN-LANGUAGE.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/PLUGIN-LANGUAGE.md).
### Get the author CLI
The plugins repository contains a command-line tool (CLI) for plugin authors. It runs the same
checks as the app, so you can find mistakes on your computer before anyone installs the plugin.
```bash
git clone https://github.com/pkuinfo/pku-harness-plugins
cd pku-harness-plugins
bun install
```
The command has this form: `bun run plugin validate|test|run [tool] [json-input]`.
### Write `PLUGIN.md`
A plugin is one folder, and `PLUGIN.md` is its main file. The file has two parts.
The YAML frontmatter at the top, between the `---` lines, is the _manifest_. It names the plugin,
lists its tools and declares which websites it may reach. The Markdown body below it is written for
the AI assistant. It tells the assistant when and how to use your tools.
```md title="my-classrooms/PLUGIN.md"
---
spec: 1
id: my-classrooms
name: 教室概况
description: 各教学楼现在有多少空闲教室。
version: 0.1.0
author: your-github-name
network: [portal.pku.edu.cn]
tools:
overview: tools/overview.yaml
i18n:
en:
name: Classroom overview
description: How many classrooms are free in each teaching building right now.
tools: { overview: { title: Free rooms by building, summary: Free classrooms in each building } }
---
my-classrooms__overview:各楼现在空闲教室数量。用户想找自习的地方时先用它挑楼。
```
`network` lists every public host the plugin may call. A request to any other host fails, so this
list is also what students see before they install. `i18n.en` holds the English text that
English-speaking users see instead of the Chinese.
The app loads only the files the frontmatter names, such as `tools/overview.yaml` here. It never
lists the folder's contents, so you can host the plugin on any static file server.
### Write the tool
A tool file describes one operation. It has fields for people, a field for the model, a declared
effect, and a list of steps that run in order.
```yaml title="my-classrooms/tools/overview.yaml"
title: 各楼空闲教室数
summary: 各教学楼现在的空闲教室数量
description: 'How many classrooms are free right now in each teaching building: building, free, total.'
effect: read
steps:
- let: r
request:
url: https://portal.pku.edu.cn/publicQuery/classroomQuery/retrClassRoomInfos.do
headers: { referer: 'https://portal.pku.edu.cn/publicQuery/', accept: 'application/json' }
as: json
- check: "r.success && isList(r.rows)"
error: 教室概况查询未成功
return: |
{
buildings: r.rows.map(b => ({ building: b.buildingName, free: b.classroomFree, total: b.classroomSum })),
}
```
The fields work as follows:
- `title` is the tool's name in the app. Write it in Chinese; the English version goes in
`i18n.en.tools.overview.title`.
- `summary` is optional. It is one line of at most 80 characters that tells people what the tool
does, and the app shows it under the title on the install review sheet. Its English version goes
in `i18n.en.tools.overview.summary`. If you leave that out, the app shows
English users no summary line.
- `description` is for the model only and is never shown to users. Say what the tool returns and
when to use it.
- `effect` is `read`, `write`, `money` or `public`. Users decide for each effect whether the
assistant may run a tool without asking, so the effect must be honest.
- The first step sends the request and binds the parsed JSON to the name `r`. The `check` step
stops with an error message if the response is not what you expect. `return` builds the result.
Return data, never instructions. The model reads the result as data.
Put quotes around any expression or `${…}` template that contains `: ` or starts with `{` or `[`.
Without quotes, YAML reads it as a map or a list instead of a string. `validate` prints a hint
when this happens.
### Record a test
A test replays a recorded response instead of calling the real website. It therefore runs offline
and gives the same result every time, even after the campus page changes. Paste a real response,
trimmed to the parts you need, as the `body`. For a long response, save it as a file next to the
test and refer to it with `file:`.
```yaml title="my-classrooms/tests/overview.yaml"
tool: overview
responses:
- match: retrClassRoomInfos.do
headers: { content-type: application/json }
body: '{"success":true,"rows":[{"buildingName":"一教","classroomFree":16,"classroomSum":32}]}'
expect: { buildings: [{ building: 一教, free: 16, total: 32 }] }
```
A test names the `tool` to run. It may also give an `input` and a fixed time, `now`. Under
`responses`, each entry is served to any request whose URL contains the `match` text, and its
content comes from `body` or `file`. For a campus request, also add `campus` with the service name.
Finish the test with one of two fields. `expect` is compared **exactly** with the tool's result.
`expectError` is a pattern that the error message must match.
### Validate, test and run
```bash
bun run plugin validate path/to/my-classrooms
bun run plugin test path/to/my-classrooms
bun run plugin run path/to/my-classrooms overview '{}'
```
`validate` checks the manifest, the tool schemas, every expression and every file path. These are
the same checks the app and the marketplace run. `test` replays the files in `tests/`. `run`
calls the tool for real, but only for public hosts. Campus requests need the app, because only the
app holds the user's sign-in, so cover them with recorded tests.
### Share it
Push the folder to a public GitHub repository. The folder's URL is the install link:
```
https://github.com///tree/main/my-classrooms
```
Other students can paste the link in the app, scan its QR code, or open
`pkuharness://plugin?src=`. Before anything is installed, the app shows a review
sheet. It says whether the plugin is community or official, lists the websites it can reach (here
`portal.pku.edu.cn`), and shows each tool with its summary and effect.
After that, every `git push` becomes an update. To list the plugin in the marketplace,
[submit the repository](/en/marketplace/submit). One repository can hold any number of plugins. See
[Publishing](/en/docs/marketplace/publishing).
## Next
- Inputs, steps, expressions, host abilities and errors: [Declarative plugins](/en/docs/build/declarative-plugins)
- Draft, validate and try a plugin together with the assistant in the app: [Write a plugin with the AI](/en/docs/build/ai-authoring)
- Requests that need the student's campus session: declare `campus` (see the spec, §4 and §9)
- What reviewers check: [Review and safety](/en/docs/marketplace/review)
# Quickstart: remote plugin (/en/docs/build/remote-plugin)
> Build a remote plugin, an MCP server that uses Streamable HTTP, from templates/remote-plugin. Run it locally, add it to the app, deploy it and register it in the marketplace.
A remote plugin is a server you run yourself. The app connects to it over the internet and calls
its tools. On this page you copy the template, write a tool, run the server on your computer, add it
to the app, and then deploy and register it.
The server speaks the [Model Context Protocol](https://modelcontextprotocol.io) (MCP), an open
standard that lets AI apps discover and call tools on a server. PKU harness uses MCP's Streamable
HTTP transport, in which each call is an HTTP `POST` request.
Users add a remote plugin by its URL or from the marketplace. Its tools then join the same registry
as the tools of bundled plugins, follow the same permissions and are called by the same assistant.
None of your code runs on the phone, and a remote plugin never sees campus credentials.
You need bun and some TypeScript. The template uses TypeScript with the official MCP SDK, but any
language with an MCP server library works the same way.
## 1. Start from the template
```bash
cd pku-harness-plugins
cp -r templates/remote-plugin ../my-plugin
cd ../my-plugin
bun install
```
The template is one file, `server.ts`, plus a `package.json` that pulls in
`@modelcontextprotocol/sdk`, `express` and `zod`:
```ts title="server.ts"
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
function build() {
// name = your registry id: the app installs the plugin as `mcp-`.
const server = new McpServer(
{ name: 'my-plugin', version: '0.1.0', title: '我的插件' },
{ instructions: 'What this plugin is for, in one sentence.' },
);
server.registerTool(
'hello',
{
title: '打个招呼',
description: 'Return a greeting. Replace with a real capability.',
inputSchema: { name: z.string().describe('who to greet') },
annotations: { readOnlyHint: true },
},
async ({ name }) => ({
content: [{ type: 'text', text: `你好,${name}` }],
structuredContent: { greeting: `你好,${name}` },
}),
);
return server;
}
const app = express();
app.use(express.json());
app.post('/mcp', async (req, res) => {
// Optional bearer auth: users paste the token in the app; it is stored in their keychain.
if (process.env.PLUGIN_TOKEN && req.headers.authorization !== `Bearer ${process.env.PLUGIN_TOKEN}`) {
res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'unauthorized' }, id: null });
return;
}
const server = build();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // stateless
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(Number(process.env.PORT ?? 8787), () => console.log('MCP plugin ready on /mcp'));
```
The `build()` function creates the server and registers its tools. The `/mcp` route checks the
optional token, then hands the request to a fresh server. Replace the `hello` tool with your own.
## 2. Write real tools
Each MCP field of a tool has a specific use in PKU harness:
- **`name`**, the first argument of `registerTool`, becomes the tool id `mcp-.`. The
model sees it as `mcp-__`. Use a short ASCII camelCase name. Other characters become
`_`, and names longer than 40 characters are cut.
- **`title`** is shown to users in chat, in approval prompts and in Settings. Write it in Chinese.
- **`description`** is shown to the model. Say when and how to use the tool, and give units and
formats.
- **`inputSchema`** is converted to zod on the device and checked before every call. Describe every
field.
- **`annotations.readOnlyHint`** sets the effect. `true` gives the effect `read`, and the tool runs
directly. If the hint is missing or `false`, the effect is `write`, and by default the app asks the
user first.
- **`structuredContent`** in the result is returned to the model as `data`. This is the preferred
form. Without it, the app tries to parse the text content as JSON, and passes it as text if that
fails.
- **`isError: true`** in the result turns the text into the error message the assistant sees.
The first 500 characters of the server's `instructions` go into the plugin's section of the system
prompt. Just before them, the app adds a line telling the model that this plugin's output is data
and must not be followed as instructions.
For now the app gives a remote tool one of two effects: `read` when `readOnlyHint: true`, and
`write` for everything else. A tool that spends money or posts publicly is therefore also `write`,
and asks first by default. Say plainly in its `title` and `description` what it does.
## 3. Run it and add it to the app
```bash
bun server.ts # MCP plugin ready on /mcp, port 8787
# optional: PLUGIN_TOKEN=secret bun server.ts
```
In the app, open **Settings → Plugins → Add remote plugin**. Enter the URL, and the token if you set
one. The URL depends on where the app runs:
| Where the app runs | URL |
| --- | --- |
| Android emulator | `http://10.0.2.2:8787/mcp` |
| Phone on the same network | `http://:8787/mcp` |
| Deployed | `https://my-plugin.example.com/mcp` |
The emulator reaches your computer at `10.0.2.2`. A real phone needs your computer's address on the
local network (LAN).
The app connects, lists your tools and shows each one with its effect on the plugin's page. Then ask
the assistant something your tool can answer. You can also ask the assistant to install the plugin
for you. It calls `plugins.installRemote`, which asks for your approval first.
`examples/demo-canteen` in `pku-harness-plugins` is a working remote plugin with mock data. It has
one read tool and one write tool, and its bearer token is `demo-token`. Run it with
`bun examples/demo-canteen/server.ts`.
## 4. Deploy
Any host that serves HTTPS will do: a virtual private server (VPS), Fly.io, Railway, Cloudflare
with an MCP-compatible runtime, or a campus server. Keep these points in mind:
- Production needs HTTPS. Use plain HTTP only for local development.
- `POST /mcp` must answer MCP Streamable HTTP requests. The template is **stateless**: it builds a
new server for each request. That suits serverless hosts, which may start a fresh process for every
request.
- Some campus hosts answer only inside the PKU network. If you call one of them, your server has to
run inside that network too. The app can't lend your server the user's campus session.
- Your own secrets, such as API keys, stay on the server. To protect the endpoint, use a bearer
token and set `"auth": "bearer"` in the registry entry.
## 5. Register it
Registering the plugin lists it in the marketplace, so students can find it. There are two ways to
do this. You can add a manifest to your repository and submit it through this website's marketplace
([manifest reference](/en/docs/marketplace/manifest), [submitting](/en/docs/marketplace/publishing)).
Or you can add an entry to `registry/registry.json` in `pku-harness-plugins`. Either way, the `id`
must match your MCP server's `name`:
```json
{
"id": "my-plugin",
"name": "我的插件",
"description": "一句话说明它做什么。",
"author": "your-github-name",
"source": "https://github.com/you/my-plugin",
"kind": "remote",
"endpoint": "https://my-plugin.example.com/mcp",
"auth": "none",
"requires": ["network"],
"scopes": ["example.com"],
"tags": ["canteen"]
}
```
Before you open a pull request against the registry, run `bun scripts/validate-registry.ts`. See
[Publishing](/en/docs/build/publishing) and the [review policy](/en/docs/marketplace/review).
## Checklist
- [ ] The MCP server's `name` equals the registry `id` (`^[a-z0-9][a-z0-9-]{1,40}$`).
- [ ] Every tool has a Chinese `title`, a `description` for the model, and described input fields.
- [ ] Read-only tools set `annotations.readOnlyHint: true`, and no other tool does.
- [ ] Results are data (`structuredContent`), never instructions, and reasonably small.
- [ ] The plugin never asks for a campus password.
- [ ] The endpoint uses HTTPS, and bearer auth is declared if you use it.
# Testing (/en/docs/build/testing)
> How to test each kind of plugin. Replay recorded responses for plugin documents, unit-test parsers and pure logic with bun test, run the assistant end to end with the scripted mock model, and try remote plugins with the demo-canteen example.
Most campus plugins have three layers: an HTTP request, a parser that reads the response, and a
tool that shapes the result. The request depends on a live campus server, so tests replace it with a
recorded response. The parser and the shaping are ordinary code that you can test on your computer.
After that, try the whole plugin in the app.
This page starts with plugin documents, then covers TypeScript plugins, the mock model, the demo
remote plugin and testing on a device. All commands run in the plugins repository unless the text
says otherwise.
## Plugin documents (PLUGIN.md)
You test a `PLUGIN.md` plugin with the author command-line tool (CLI) in the plugins repository.
It replays the recorded responses in the plugin's `tests/*.yaml`:
```bash
bun run plugin validate plugins/classrooms # the checks the app runs
bun run plugin test plugins/classrooms # replay tests/*.yaml
bun run plugin run plugins/classrooms overview '{}' # live, public hosts only
```
`validate` runs the same checks the app runs at install time. `test` replays `tests/*.yaml`. `run`
sends real requests, and only to public hosts.
`bun test` includes `plugins/documents.test.ts`. It loads every `plugins//PLUGIN.md` and replays
all of its recorded tests. Continuous integration (CI) runs `bun test` on every push to `main` and
every pull request, so it fails as soon as a document or its recorded responses (fixtures) break.
The test format is described in the [quickstart](/en/docs/build/quickstart#record-a-test). The rest
of this page covers TypeScript plugins.
## Unit tests (bun test)
Run these in the plugins repository:
```bash
bun test # every *.test.ts in the workspace
bun test plugins/focus # one plugin
bunx tsc --noEmit -p . # typecheck SDK + plugins
```
The commands run every test in the workspace, the tests of one plugin, and a type check of the SDK
and all plugins. `focus` is the example plugin from [Bundled plugin](/en/docs/build/bundled-plugin);
use your own plugin's folder instead.
A few habits make these tests useful:
- Put tests next to the code: `src/parse.test.ts` beside `src/parse.ts`.
- **Parsers.** Save real responses as fixtures (`src/fixtures/*.html|json`), replace any personal
data in them, and assert on the parsed result. Campus pages change from time to time, and when
they do, a fixture shows exactly what broke.
- **Time.** Pass `now` in as an argument instead of reading `Date.now()` inside your logic. A test
can then fix the time. The SDK's recurrence, timetable and format helpers all take `now`.
- **React Native.** Modules that import `react-native` won't load under bun unless you mock it.
Keep logic in plain modules, or mock `react-native` the way `plugins/i18n.test.ts` does:
```ts
import { mock } from 'bun:test';
mock.module('react-native', () => ({
StyleSheet: { create: (s: unknown) => s },
Platform: { OS: 'android', select: (o: Record) => o.android ?? o.default },
View: () => null,
}));
```
### Testing a tool with a fake context
A tool's `execute` function takes its input and a context object, `ctx`. In a test you can build a
small fake context yourself. Here the fake context has an in-memory KV store, so the test can start
a session and then read it back:
```ts title="plugins/focus/src/index.test.ts"
import { expect, mock, test } from 'bun:test';
import type { KV, ToolContext } from '@pku-harness/sdk';
// index.ts imports the widget, which imports react-native: mock it before loading the plugin.
mock.module('react-native', () => ({ View: () => null }));
const { focusPlugin } = await import('./index');
function memoryKV(): KV {
const m = new Map();
return {
get: async (k: string) => m.get(k) as T | undefined,
set: async (k, v) => void m.set(k, v),
delete: async (k) => void m.delete(k),
};
}
test('start, then today shows the running session', async () => {
const ctx = { pluginId: 'focus', actor: 'user', kv: memoryKV(), services: {} } as unknown as ToolContext;
await focusPlugin.tools.start.execute({ label: '高数' }, ctx);
const today = (await focusPlugin.tools.today.execute({}, ctx)) as { running: { label: string } | null };
expect(today.running?.label).toBe('高数');
});
```
`index.ts` imports the widget, which imports `react-native`, so the test mocks it before loading
the plugin.
`execute` doesn't validate its input; the app does that before calling it. If validation matters,
test it separately with `tool.input.safeParse(...)`.
## Mock model
The assistant works in a loop: it sends the conversation to a model, runs the tools the model asks
for, and sends the results back. To test that loop without an API key or a real model, use
`examples/mock-llm`. It is a small server with the same interface as OpenAI's Chat Completions API,
and it answers from a fixed script:
```bash
bun examples/mock-llm/server.ts # http://localhost:8788/v1, model "mock-agent"
```
In the app, go to Settings → AI service → OpenAI-compatible. Set the base URL to
`http://10.0.2.2:8788/v1`, which is how the emulator reaches your computer, and set the model to
`mock-agent`. The mock model picks a tool based on keywords in your message, then summarises the
tool's result:
| Message contains | It calls |
| --- | --- |
| `提醒` / `remind` | `scheduler__create` (every 3 days at 09:00, 16 times) |
| `马上` / `soon` | `scheduler__create` (once, in 2 minutes) |
| `看板` / `dash` | `dashboard__upsertWidget` (a widget bound to `timetable.today`) |
| `表单` / `form` | `ui__render` (a top-up form) |
| `remember` | `memory__remember` |
| `食堂` / `canteen` | `mcp-demo-canteen__reserveSeat` (needs the demo plugin installed) |
| `who` | Echoes the injected `` block |
To make it call your own tools, extend its `plan()` function locally with their model names.
## Demo remote plugin
```bash
bun examples/demo-canteen/server.ts # :8787/mcp, bearer token demo-token
```
`examples/demo-canteen` is a remote plugin with mock data. It listens on port 8787 at `/mcp` and
expects the bearer token `demo-token`. In Settings → Plugins → Add remote plugin, add
`http://10.0.2.2:8787/mcp` with the token `demo-token`. It has one read tool (`menu`) and one write
tool (`reserveSeat`), so you can see the approval prompt that remote write tools show.
## On a device
- Development builds accept special deep links that run a tool or a chat turn directly:
`pkuharness://dev?tool=.&input=` and `pkuharness://dev?chat=`.
Add `&actor=agent` to run the tool as the assistant, which shows the ask-first prompts.
- Check screens and widgets in the dark and the light theme, and in both languages.
- Real campus data needs a real account. Test read tools on your own account before you ask anyone
else to, and never commit responses that contain personal data.
# Tools and schemas (/en/docs/build/tools)
> 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](/en/docs/build/declarative-plugins#tools);
the advice on descriptions, effects and results applies there too. The ideas behind tools are
explained in [Tools and effects](/en/docs/concepts/tools-and-effects).
## Naming
A tool has several names, each for a different reader:
| Name | Rule | Example |
| --- | --- | --- |
| Tool name | ASCII camelCase verb or noun, unique in the plugin | `cardBalance`, `search`, `reserveSeat` |
| Tool id | `.` (set automatically) | `wallet.cardBalance` |
| Model name | `__` (set automatically) | `wallet__cardBalance` |
| `title` | Short 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..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..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:
```ts
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.
```ts
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](/en/docs/concepts/permissions)).
```ts
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](/en/docs/concepts/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
```ts
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:
```ts
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:
```ts
// 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.
# UI contributions (/en/docs/build/ui)
> How a bundled plugin adds screens, Settings rows, tab content, dashboard widgets and slash commands through `contributes`, using the components the app passes in as HostUI.
This page explains how a bundled plugin adds its own UI to the app. It assumes you have read
[Bundled plugin](/en/docs/build/bundled-plugin). Plugin documents describe their widgets and screens
as generative-UI JSON instead; see [Declarative plugins](/en/docs/build/declarative-plugins).
A bundled plugin adds native UI without importing any app code. Each screen and widget receives an
object called `HostUI` through its props. It holds the app's components, theme tokens, haptics,
navigation and data hooks. Because the plugin draws everything with these, its UI looks like the rest
of the app and keeps working when the app changes.
Everything a plugin adds is listed under `contributes` in `definePlugin`:
```ts
contributes: {
screens: { main: { title: '专注', component: FocusScreen } },
settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
widgets: { today: { title: '今日专注', description: '…', sizes: ['sm', 'md'], component: FocusWidget } },
commands: { focus: { description: '打开专注', aliases: ['专注'], run: (_i, { navigate }) => navigate('/p/focus/main') } },
tabs: [{ slot: 'schedule', screen: 'main' }], // only for plugins that own a tab
},
```
## Screens
```ts
interface ScreenContribution {
title: string;
component: ComponentType;
presentation?: 'push' | 'modal'; // modal → /m// (forms); default push → /p//
}
```
A screen is a React component. By default it opens as a normal page (`push`). Set
`presentation: 'modal'` for forms. The component receives `PluginScreenProps`: `host`, `services`, `kv`,
`invoke(toolId, input?)`, `params` (the query parameters of the route) and `cloud?`.
```tsx
import type { PluginScreenProps } from '@pku-harness/sdk';
import { useState } from 'react';
export function EditScreen({ host, params, invoke }: PluginScreenProps) {
const { SheetScreen, Field } = host.components;
const [text, setText] = useState(params.text ?? '');
return (
{ await invoke('memory.update', { id: params.id, text }); host.dismiss(); }}>
);
}
```
To open a screen, call `host.navigate('/p//')`, or
`host.navigate('/m//?id=…')` for a modal. Close a modal with `host.dismiss()`. Use
`host.components.Screen` as the root of a push screen, and `SheetScreen` as the root of a modal
form.
## Settings rows
```ts
interface SettingsContribution { screen: string; title: string; subtitle?: string; icon?: string }
```
A Settings row is a shortcut to one of your screens. Each row opens the screen it names. Apart from
these rows, the plugin's page under Settings → Plugins lists its screens, widgets, tools and
permissions.
## Widgets
```ts
interface WidgetContribution {
title: string;
description: string; // for the agent: what it shows and what config does
sizes?: ('sm' | 'md' | 'lg')[];
defaultSize?: 'sm' | 'md' | 'lg';
configSchema?: z.ZodType>;
component: ComponentType; // host, services, kv, invoke, size, config, cloud?
}
```
A widget is a card on the dashboard. Keep these points in mind when you write one:
- The dashboard draws the card's frame: the title, the background, how fresh the data is, and the
long-press menu. You draw only the content. Adapt it to `size`: `sm` is a half-width tile, `md`
is full width, and `lg` is a large feature card.
- Load data with `host.useToolData(tool, args, { ttlMinutes })` (see
[Dashboard](/en/docs/concepts/dashboard#refresh-owned-by-the-host)). The app decides when to
refresh, so don't poll.
- Handle three states. While `status` is `'loading'`, show a skeleton. When there is no value, show
the error text and let the user retry with `data.refresh()`. Also handle the signed-out state.
- The assistant or the user picks the widget's `config`, which is validated against
`configSchema`. Describe each field with `.describe()`, and mention the fields in `description`
("config.names limits it to certain canteens") so the assistant knows they exist.
- Each widget is mounted inside an error boundary. If it crashes, the dashboard shows a notice in
its place and the other widgets keep working.
## Slash commands
```ts
type CommandContribution = {
description: string; // one line in the / menu
aliases?: string[]; // e.g. Chinese names: ['余额']
argHint?: string; // placeholder; present = the command takes input
} & (
| { prompt: string } // sent as the user's message; {{input}} is replaced
| { run: (input: string, ctx: { services: HostServices; navigate: (href: string) => void; locale: AppLocale }) => Promise | string | void }
);
```
A slash command is an entry in the `/` menu of the chat composer. Command names are lowercase
(`^[a-z][a-z0-9-]*$`). If two plugins use the same name, the app prefixes both with the plugin id,
as `/plugin:name`.
A command does one of two things. A `prompt` command sends text to the assistant as if the user had
typed it. A `run` command runs on the device; the string it returns appears as a notice in the chat
and never reaches the model.
```ts
commands: {
canteen: { description: '现在去哪个食堂', aliases: ['食堂', '吃饭'], argHint: '想吃什么 / 在哪(可空)', prompt: '现在哪些食堂还开着、人少?帮我挑一个{{input}}' },
shuttle: { description: '下一班校车(本地计算)', aliases: ['校车'], argHint: '如 燕园到新燕园', run: (input, { locale }) => quickAnswer(input, Date.now(), locale) },
},
```
## Tabs
The app has two tab slots, `dashboard` and `schedule`. Each is filled by the enabled plugin that
contributes it, which today is the `dashboard` plugin and the `schedule` plugin. The Chat and
Settings tabs belong to the app itself. Contribute a tab only if you are replacing one of those two
plugins.
## HostUI essentials
These are the members of `host` you will use most:
| Member | Use |
| --- | --- |
| `host.components` | `Screen`, `SheetScreen`, `Text`, `Button`, `IconButton`, `ListGroup`, `ListRow`, `Chip`, `Surface`, `Glass`, `Stat`, `Ring`, `Sparkline`, `Field`, `DateTimeField`, `Switch`, `Checkbox`, `Segmented`, `Stepper`, `WeekdayPicker`, `SortableList`, `EmptyState`, `Skeleton`, `FadeIn`, `Icon`, `QRCode`, `GenUI`, `PluginWidget`, `Row`, `Column`, `Pressable`, `Hourglass` |
| `host.useTheme()` | Tokens: `colors`, `spacing`, `radius`, `typography`, `glass`, `courseColor(i)`, `contentWidth` |
| `host.useToolData`, `useWidgetData`, `useBindings`, `refreshAll` | Live data with the shared cache |
| `host.useLocale()` | `'zh-Hans'` or `'en'` |
| `host.navigate`, `back`, `dismiss`, `ask(text)` | Navigation; `ask` switches to chat and sends a message |
| `host.haptics` | `tap`, `select`, `success`, `warning` (respects user settings) |
| `host.useNow(ms)`, `formatAgo(ms)` | The current time, updated every `ms`; relative time |
| `host.useSecureDisplay({ brightness })` | Block screenshots while mounted (payment codes) |
| `host.useStoreVersion()` | Re-render when jobs, to-dos, widgets or the timetable change |
| `host.clipboard?`, `host.shareLink?` | Optional; check before use |
Full signatures are in [SDK → ui](/en/docs/sdk/ui).
## Design rules
- **MUST** use `host.components` and theme tokens. No hard-coded colours, font sizes or spacing.
- **MUST** keep all user-facing text in your `strings.ts` table, picked with `host.useLocale()`.
- **MUST** use icon names from the host's icon set: `card`, `wifi`, `book`, `bell`, `clock`,
`location`, `sparkles`, `key`, `person`, `qr`, `warning`, `check`, `calendar`, `bus`, `food`,
`wallet`, `library`, `room`, `school`, `ticket`, `medal`, `chart`, `search`, `globe`, `lock`, … (the
full list is `src/components/icon.tsx` in the app).
- **SHOULD** give tappable custom content an `accessibilityLabel`.
- Copy: Chinese text is short and has no exclamation marks. English text is in sentence case and uses
curly apostrophes (’).
# Channels (owner) (/en/docs/cloud/api/channels)
> End-to-end encrypted relay channels opened by a device, used for live sharing with people who open a share link. The owner talks to its peers over a WebSocket; the server only forwards envelopes.
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/channels
**Open a channel** (`createChannel`)
Opens a relay channel owned by the calling device and plugin. The client generates the share
secret `k`, derives `authKey = HKDF(k, "pku-harness-cloud/v1/join")` and
`encKey = HKDF(k, "pku-harness-cloud/v1/enc")`, and sends only `joinHash = base64url(sha256(authKey))`,
the channel's X25519 public key and `meta` sealed with `encKey`. The server can gate joins
but can't read anything. Limits: 5 open channels per device, TTL 60 s – 24 h (default 15 min).
Auth: `pluginToken`
Request body (`application/json`): `CreateChannelRequest`
- **200**: The channel id and expiry. → `application/json` `CreateChannelResponse`
- **default**: see [Error](#response-error) below
## PATCH /v1/channels/{id}
**Extend a channel** (`extendChannel`)
Sets the expiry to now + `ttlS`, capped at `limits.channels.maxTtlS`. Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
Request body (`application/json`): `PatchChannelRequest`
- **200**: The new expiry. → `application/json` `PatchChannelResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/channels/{id}
**Close a channel** (`closeChannel`)
Closes the channel; connected peers receive `{ t: "closed", reason: "owner" }`. Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Closed. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## GET /v1/channels/{id}/ws
**Owner WebSocket** (`ownerSocket`)
Upgrades to a WebSocket for the channel owner. Every frame is one JSON object.
- Server → owner: `OwnerInFrame` (`hello`, `join`, `leave`, `peer` presence, `msg`, `closed`, `error`, `pong`).
- Owner → server: `OwnerOutFrame` (`msg` to one peer, `kick` a peer and free its seat, `ping`).
Frames carry envelopes sealed with the per-peer key
`HKDF(X25519(chanPriv, peerPub) ‖ encKey, salt = chanPub ‖ peerPub, info = "pku-harness-cloud/v1/peer")`.
Limits: frames ≤ 4 KB of ciphertext, ≤ 60 frames per minute per party (the owner gets
60 × maxPeers); a violating frame is dropped with an `error` frame. A frame to an offline
party is dropped with `error: conflict` (use the mailbox for offline delivery).
The token may also be passed as `?t=` (WebSocket clients can't always set headers). Close
codes: `4003` kicked, `4009` replaced by a newer connection of the same party, `4010` channel
closed or expired (a `closed` frame comes first), `1001` server shutdown (reconnect).
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **101**: Switching to the WebSocket protocol. Frames are described by the `OwnerInFrame` and `OwnerOutFrame` schemas.
- **default**: see [Error](#response-error) below
## DELETE /v1/channels/{id}/mailbox
**Clear the mailbox** (`clearMailbox`)
Removes every mailbox slot of the channel (`Channel.mailbox.clear()` in the SDK). Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Cleared. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## DELETE /v1/channels/{id}/mailbox/{slot}
**Delete a mailbox slot** (`deleteMailboxSlot`)
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `slot` | path | `MailboxSlot` | yes | |
- **200**: Deleted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## PUT /v1/channels/{id}/mailbox/{slot}
**Put a sealed blob in a mailbox slot** (`putMailboxSlot`)
Batch mode. Sealed blobs that peers can read while the owner is offline (at most `limits.channels.mailboxSlots` slots). Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `slot` | path | `MailboxSlot` | yes | |
Request body (`application/json`): `MailboxPutRequest`
- **200**: Stored. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ChannelId
Server-generated, base64url (16 random bytes → 22 characters). pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type ChannelId = string;
```
### CloseReason
```ts
type CloseReason = "owner" | "expired" | "revoked" | "locked" | "server";
```
### CreateChannelRequest
```ts
type CreateChannelRequest = {
ttlS: number; // minimum 1
maxPeers?: number; // minimum 1, default 1
joinHash: Key32;
meta: Envelope;
chanPub: Key32;
page?: PageName;
};
```
### CreateChannelResponse
```ts
type CreateChannelResponse = {
id: ChannelId;
expiresAt: Ms;
};
```
### Envelope
XChaCha20-Poly1305 sealed JSON. `n` = 24-byte random nonce, `c` = ciphertext ‖ 16-byte tag. Open it with `open()` from `@pku-harness/cloud-protocol` and validate the result.
```ts
type Envelope = {
v: 1;
n: Nonce24;
c: string; // pattern `^[A-Za-z0-9_-]*$`, minLength 22
};
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Key32
32 bytes, base64url (Ed25519 or X25519 public key, hash). pattern `^[A-Za-z0-9_-]{43}$`
```ts
type Key32 = string;
```
### MailboxPutRequest
```ts
type MailboxPutRequest = {
env: Envelope;
ttlS: number; // minimum 1
};
```
### MailboxSlot
pattern `^[A-Za-z0-9._:-]{1,64}$`
```ts
type MailboxSlot = string;
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce24
24 bytes, base64url. pattern `^[A-Za-z0-9_-]{32}$`
```ts
type Nonce24 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### OwnerInFrame
Server → owner WebSocket frame.
```ts
type OwnerInFrame = {
t: "hello";
id: ChannelId;
expiresAt: Ms;
peers: {
peerId: PeerId;
peerPub: Key32;
joinedAt: Ms;
online: boolean;
}[];
} | {
t: "join";
peerId: PeerId;
peerPub: Key32;
at: Ms;
} | {
t: "leave";
peerId: PeerId;
at: Ms;
} | {
t: "peer";
peerId: PeerId;
online: boolean;
at: Ms;
} | {
t: "msg";
peerId: PeerId;
env: Envelope;
} | {
t: "closed";
reason: CloseReason;
} | {
t: "error";
code: WireErrorCode;
message: string;
} | {
t: "pong";
at: Ms;
};
```
### OwnerOutFrame
Owner → server WebSocket frame. `kick` drops a peer and frees its seat (重置查看者).
```ts
type OwnerOutFrame = {
t: "msg";
to: PeerId;
env: Envelope;
} | {
t: "kick";
peerId: PeerId;
} | {
t: "ping";
};
```
### PageName
pattern `^[a-z0-9][a-z0-9-]{0,40}$`
```ts
type PageName = string;
```
### PatchChannelRequest
```ts
type PatchChannelRequest = {
ttlS: number; // minimum 1
};
```
### PatchChannelResponse
```ts
type PatchChannelResponse = {
expiresAt: Ms;
};
```
### PeerId
pattern `^[A-Za-z0-9_-]{4,64}$`
```ts
type PeerId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Devices and auth (/en/docs/cloud/api/devices)
> A device's cloud identity is an Ed25519 signing key plus an X25519 box key, nothing else: no student ID, no name. Registration costs a proof of work; plugin tokens are minted from a signed challenge and live 10 minutes.
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/devices
**Register a device** (`registerDevice`)
Registers a device identity. Authenticated by proof of work instead of a token: find a
decimal `nonce` such that `SHA-256(prefix ‖ UTF-8(nonce))` starts with at least
`pow.difficulty` zero bits (from `GET /v1/meta`), where
```
prefix = UTF-8("pku-harness-cloud/v1/pow\n" + salt + "\n" + signPub + "\n" + boxPub + "\n")
```
The server accepts its current salt and the previous one. Binding the keys into the prefix
means one solution registers exactly one identity. Clients refuse a difficulty above 28
(`MAX_POW_DIFFICULTY`); the default is 16 (about one second on a phone). Use `solvePow` from
`@pku-harness/cloud-protocol`.
Auth: none
Request body (`application/json`): `RegisterDeviceRequest`
- **200**: The server-generated device id. → `application/json` `RegisterDeviceResponse`
- **default**: see [Error](#response-error) below
## POST /v1/auth/token
**Mint a plugin token** (`createToken`)
Exchanges an Ed25519-signed challenge for a 10-minute bearer token bound to one device, one
plugin and a set of services (claims `sub = deviceId`, `plg = plugin`, `svc = services`).
The signed bytes are these lines joined by `\n` (no trailing newline):
```
pku-harness-cloud/v1/auth
provider origin, lowercase scheme://host[:port], no path
deduplicated, sorted, joined by ","
Unix ms, decimal
16 random bytes, base64url
```
The server compares `aud` with its own origin, accepts `|now − ts| ≤ 5 min` and rejects a
reused nonce within that window. Use `signAuthChallenge` from
`@pku-harness/cloud-protocol`.
Auth: none
Request body (`application/json`): `AuthTokenRequest`
- **200**: A bearer token for this plugin's requests. → `application/json` `AuthTokenResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/devices/me
**Delete this device and all its data** (`deleteDevice`)
Wipes every record of the device (store, channels, push schedules). Any plugin token of the device works. The app calls it from Settings → 删除云端数据 and before a full reset.
Auth: `pluginToken`
- **200**: Deleted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### AuthTokenRequest
```ts
type AuthTokenRequest = {
aud: string; // pattern `^https?:\/\/[a-z0-9.\-:[\]]+$`
deviceId: DeviceId;
plugin: PluginId;
services: CloudService[]; // minItems 1
ts: Ms;
nonce: Nonce16;
sig: Sig64;
};
```
### AuthTokenResponse
```ts
type AuthTokenResponse = {
token: string; // minLength 16
expiresAt: Ms;
};
```
### CloudService
```ts
type CloudService = "store" | "channels" | "push" | "files" | "functions";
```
### DeviceId
Server-generated, base64url. pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type DeviceId = string;
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Key32
32 bytes, base64url (Ed25519 or X25519 public key, hash). pattern `^[A-Za-z0-9_-]{43}$`
```ts
type Key32 = string;
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce16
16 bytes, base64url. pattern `^[A-Za-z0-9_-]{22}$`
```ts
type Nonce16 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### RegisterDeviceRequest
```ts
type RegisterDeviceRequest = {
signPub: Key32;
boxPub: Key32;
pow: {
salt: string; // pattern `^[A-Za-z0-9_-]{1,64}$`
nonce: string; // pattern `^(0|[1-9][0-9]{0,14})$`
};
};
```
### RegisterDeviceResponse
```ts
type RegisterDeviceResponse = {
deviceId: DeviceId;
};
```
### Sig64
64-byte Ed25519 signature, base64url. pattern `^[A-Za-z0-9_-]{86}$`
```ts
type Sig64 = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Functions (planned) (/en/docs/cloud/api/functions)
> Server functions declared in a plugin's `CloudManifest.functions`. Planned for stage 6.
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/fn/{plugin}/{name}
**Invoke a server function (planned)** (`invokeFunction`)
**Planned (stage 6).** The reference server answers `501 unsupported` until then. Runs a function the plugin declared in `CloudManifest.functions`, on
the user's provider. A function is `name@version`; the server keeps the previous major for
90 days. Default limit: 60 calls per minute.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `plugin` | path | `PluginId` | yes | |
| `name` | path | `FunctionName` | yes | |
Request body (`application/json`): `FunctionRequest`
- **200**: The function output. → `application/json` `FunctionResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### FunctionName
pattern `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`
```ts
type FunctionName = string;
```
### FunctionRequest
```ts
type FunctionRequest = {
input?: unknown;
version?: number; // minimum 1
};
```
### FunctionResponse
```ts
type FunctionResponse = {
output?: unknown;
};
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Meta (/en/docs/cloud/api/meta)
> Provider discovery. The host reads it when the user connects a provider and checks `protocol`, `services` and `limits`.
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /v1/meta
**Provider metadata** (`getMeta`)
Protocol version, offered services, limits, proof-of-work parameters, viewer pages, server functions and the operator. No authentication.
Auth: none
- **200**: Provider metadata. → `application/json` `MetaResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### CloudService
```ts
type CloudService = "store" | "channels" | "push" | "files" | "functions";
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### FunctionName
pattern `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`
```ts
type FunctionName = string;
```
### Limits
Defaults (`DEFAULT_LIMITS`): store 1,000,000 bytes / 1,000 keys / 64,000 bytes per value; channels 5 open, TTL 60–86,400 s (default 900), 5 peers, 4,096-byte frames, 60 frames/min, 64 mailbox slots; push 100 pending; functions 60 calls/min; 20 joins per IP per minute.
```ts
type Limits = {
store: {
maxBytes: number;
maxKeys: number;
maxValueBytes: number;
};
channels: {
maxOpen: number;
minTtlS: number;
maxTtlS: number;
defaultTtlS: number;
maxPeers: number;
frameBytes: number;
framesPerMin: number;
mailboxSlots: number;
};
push?: {
maxPending: number;
};
functions?: {
callsPerMin: number;
};
joinsPerIpPerMin: number;
};
```
### MetaResponse
```ts
type MetaResponse = {
protocol: 1;
services: CloudService[];
limits: Limits;
pow: {
difficulty: number; // minimum 0, maximum 64
salt: string; // pattern `^[A-Za-z0-9_-]{1,64}$`
};
pages: {
plugin: PluginId;
page: PageName;
}[];
functions: {
plugin: PluginId;
name: FunctionName;
version: number; // minimum 1
}[];
operator: {
name: string; // minLength 1
contact: string;
privacyURL?: string;
};
};
```
### PageName
pattern `^[a-z0-9][a-z0-9-]{0,40}$`
```ts
type PageName = string;
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Share links and pages (/en/docs/cloud/api/pages)
> Public redirects and static viewer pages for people without the app.
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /s/{id}
**Open a share link** (`shareRedirect`)
Redirects to the viewer page the channel was opened with, `/w//?c=`. The browser keeps the `#k=` fragment across the redirect; the server never sees it.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **302**: Redirect to the plugin's viewer page.
- **default**: see [Error](#response-error) below
## GET /w/{plugin}/{page}
**Static viewer page** (`viewerPage`)
A static page built from `plugins//web` and listed in `CloudManifest.pages`, served with a strict CSP (`connect-src 'self'`). It reads `#k` from `location.hash`, decrypts locally with `@pku-harness/cloud-protocol` and talks to `/v1/c/{id}/…`.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `plugin` | path | `PluginId` | yes | |
| `page` | path | `PageName` | yes | |
- **200**: The page. → `text/html` `string`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ChannelId
Server-generated, base64url (16 random bytes → 22 characters). pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type ChannelId = string;
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### PageName
pattern `^[a-z0-9][a-z0-9-]{0,40}$`
```ts
type PageName = string;
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Channels (peer) (/en/docs/cloud/api/peers)
> What a viewer (a web page or another app) calls after opening a share link `https:///s/#k=`. The fragment `k` never reaches the server; the viewer derives `authKey` from it to join.
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /v1/c/{id}
**Public channel info** (`getChannelInfo`)
What a viewer reads before joining. `meta` is sealed with `encKey`, so only someone holding the link can read it. Public and rate-limited (20 requests per IP per minute by default).
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Channel status, sealed meta and the channel public key. → `application/json` `ChannelInfoResponse`
- **default**: see [Error](#response-error) below
## POST /v1/c/{id}/join
**Join a channel** (`joinChannel`)
Claims a seat. The server checks `sha256(authKey)` against the channel's `joinHash` and
enforces `maxPeers`: when the channel is full the answer is `409 conflict`, so a forwarded
link is useless once the first viewer claimed it. `peerPub` is the viewer's ephemeral X25519
key.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
Request body (`application/json`): `JoinRequest`
- **200**: The peer id and a peer token for the socket, SSE stream, send and mailbox endpoints. → `application/json` `JoinResponse`
- **default**: see [Error](#response-error) below
## GET /v1/c/{id}/ws
**Peer WebSocket** (`peerSocket`)
Upgrades to a WebSocket for a joined peer.
- Server → peer: `PeerInFrame` (`hello`, `owner` online state, `extended`, `msg`, `closed`, `error`, `pong`).
- Peer → server: `PeerOutFrame` (`msg`, `ping`).
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `t` | query | `string` | yes | The `peerToken` from `POST /v1/c/{id}/join`. minLength 16 |
- **101**: Switching to the WebSocket protocol. Frames are described by the `PeerInFrame` and `PeerOutFrame` schemas.
- **default**: see [Error](#response-error) below
## GET /v1/c/{id}/events
**Peer event stream (SSE)** (`peerEvents`)
Fallback for webviews without WebSocket (WeChat). Each `data:` line is one `PeerInFrame` as JSON. Send with `POST /v1/c/{id}/send`.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `t` | query | `string` | yes | The `peerToken` from `POST /v1/c/{id}/join`. minLength 16 |
- **200**: Server-sent events. → `text/event-stream` `PeerInFrame`
- **default**: see [Error](#response-error) below
## POST /v1/c/{id}/send
**Send a frame (SSE clients)** (`peerSend`)
The body is one `PeerOutFrame`, as it would be sent on the WebSocket.
Auth: `peerToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
Request body (`application/json`): `PeerOutFrame`
- **200**: Accepted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## GET /v1/c/{id}/mailbox
**Read the mailbox** (`peerMailbox`)
Batch mode. The sealed blobs the owner left in the channel's mailbox.
Auth: `peerToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Mailbox slots. → `application/json` `MailboxListResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ChannelId
Server-generated, base64url (16 random bytes → 22 characters). pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type ChannelId = string;
```
### ChannelInfoResponse
```ts
type ChannelInfoResponse = {
status: ChannelStatus;
meta: Envelope;
chanPub: Key32;
expiresAt: Ms;
ownerOnline: boolean;
};
```
### ChannelStatus
```ts
type ChannelStatus = "open" | "closed" | "expired";
```
### CloseReason
```ts
type CloseReason = "owner" | "expired" | "revoked" | "locked" | "server";
```
### Envelope
XChaCha20-Poly1305 sealed JSON. `n` = 24-byte random nonce, `c` = ciphertext ‖ 16-byte tag. Open it with `open()` from `@pku-harness/cloud-protocol` and validate the result.
```ts
type Envelope = {
v: 1;
n: Nonce24;
c: string; // pattern `^[A-Za-z0-9_-]*$`, minLength 22
};
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### JoinRequest
```ts
type JoinRequest = {
authKey: Key32;
peerPub: Key32;
};
```
### JoinResponse
```ts
type JoinResponse = {
peerId: PeerId;
peerToken: string; // minLength 16
expiresAt: Ms;
};
```
### Key32
32 bytes, base64url (Ed25519 or X25519 public key, hash). pattern `^[A-Za-z0-9_-]{43}$`
```ts
type Key32 = string;
```
### MailboxListResponse
```ts
type MailboxListResponse = {
items: {
slot: MailboxSlot;
env: Envelope;
expiresAt: Ms;
}[];
};
```
### MailboxSlot
pattern `^[A-Za-z0-9._:-]{1,64}$`
```ts
type MailboxSlot = string;
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce24
24 bytes, base64url. pattern `^[A-Za-z0-9_-]{32}$`
```ts
type Nonce24 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### PeerId
pattern `^[A-Za-z0-9_-]{4,64}$`
```ts
type PeerId = string;
```
### PeerInFrame
Server → peer frame (WebSocket message or SSE `data:` line).
```ts
type PeerInFrame = {
t: "hello";
peerId: PeerId;
ownerOnline: boolean;
expiresAt: Ms;
} | {
t: "owner";
online: boolean;
} | {
t: "extended";
expiresAt: Ms;
} | {
t: "msg";
env: Envelope;
} | {
t: "closed";
reason: CloseReason | "kicked";
} | {
t: "error";
code: WireErrorCode;
message: string;
} | {
t: "pong";
at: Ms;
};
```
### PeerOutFrame
Peer → server frame (WebSocket message, or the body of `POST /v1/c/{id}/send`).
```ts
type PeerOutFrame = {
t: "msg";
env: Envelope;
} | {
t: "ping";
};
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Push (planned) (/en/docs/cloud/api/push)
> Push tokens and scheduled pushes with end-to-end encrypted payloads. Planned for stage 6.
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/push/tokens
**Register a push token (planned)** (`registerPushToken`)
**Planned (stage 6);** `501 unsupported` until then. UnifiedPush (ntfy) on Android, APNs on iOS, FCM optional.
Auth: `pluginToken`
Request body (`application/json`): `PushTokenRequest`
- **200**: Registered. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## POST /v1/push/schedule
**Schedule a push (planned)** (`schedulePush`)
**Planned (stage 6);** `501 unsupported` until then. The payload is an envelope: the server learns the time only. At most 100 pending schedules.
Auth: `pluginToken`
Request body (`application/json`): `PushScheduleRequest`
- **200**: The schedule id. → `application/json` `PushScheduleResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/push/schedule
**Cancel a scheduled push (planned)** (`cancelPush`)
**Planned (stage 6);** `501 unsupported` until then.
Auth: `pluginToken`
Request body (`application/json`): `PushCancelRequest`
- **200**: Cancelled. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### Envelope
XChaCha20-Poly1305 sealed JSON. `n` = 24-byte random nonce, `c` = ciphertext ‖ 16-byte tag. Open it with `open()` from `@pku-harness/cloud-protocol` and validate the result.
```ts
type Envelope = {
v: 1;
n: Nonce24;
c: string; // pattern `^[A-Za-z0-9_-]*$`, minLength 22
};
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce24
24 bytes, base64url. pattern `^[A-Za-z0-9_-]{32}$`
```ts
type Nonce24 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### PushCancelRequest
```ts
type PushCancelRequest = {
id: string; // minLength 1
};
```
### PushScheduleRequest
```ts
type PushScheduleRequest = {
at: Ms;
payload: Envelope;
};
```
### PushScheduleResponse
```ts
type PushScheduleResponse = {
id: string; // minLength 1
};
```
### PushTokenRequest
```ts
type PushTokenRequest = {
kind: "unifiedpush" | "apns" | "fcm";
token: string; // minLength 1, maxLength 2048
};
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# Store (/en/docs/cloud/api/store)
> Key-value store namespaced by `(deviceId, plugin)` taken from the token, never from the path. Values are envelopes when the plugin declares `e2e`.
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /v1/store
**List keys** (`listStore`)
Keys of the calling `(device, plugin)` namespace, optionally filtered by prefix. Values are not included.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `prefix` | query | `string` | no | Only keys starting with this string. |
- **200**: Matching keys. → `application/json` `StoreListResponse`
- **default**: see [Error](#response-error) below
## GET /v1/store/{key}
**Read a value** (`getStoreValue`)
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `key` | path | `StoreKey` | yes | Path-like key (`/` allowed, each segment URL-encoded), no `.` or `..` segments. |
- **200**: The value with its version. → `application/json` `StoreGetResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/store/{key}
**Delete a value** (`deleteStoreValue`)
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `key` | path | `StoreKey` | yes | Path-like key (`/` allowed, each segment URL-encoded), no `.` or `..` segments. |
- **200**: Deleted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## PUT /v1/store/{key}
**Write a value** (`putStoreValue`)
Creates or replaces a value. Optimistic concurrency with `If-Match: `: the write
fails with `409 conflict` unless the stored version matches; `If-Match: 0` means the key must
not exist yet. Default quotas: 1 MB and 1,000 keys per plugin per device, 64 KB per value
(`limits.store` in `GET /v1/meta`).
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `key` | path | `StoreKey` | yes | Path-like key (`/` allowed, each segment URL-encoded), no `.` or `..` segments. |
| `If-Match` | header | `string` | no | Expected current version; `0` = the key must not exist. pattern `^[0-9]+$` |
Request body (`application/json`): `StorePutRequest`
- **200**: The new version (also sent as the `ETag` header). → `application/json` `StorePutResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### StoreGetResponse
```ts
type StoreGetResponse = {
key: StoreKey;
value?: unknown;
version: number; // minimum 1
updatedAt: Ms;
expiresAt?: Ms;
};
```
### StoreKey
Path-like key; no `.` or `..` segments. pattern `^[A-Za-z0-9._:/-]{1,256}$`
```ts
type StoreKey = string;
```
### StoreListResponse
```ts
type StoreListResponse = {
items: {
key: StoreKey;
version: number; // minimum 1
updatedAt: Ms;
}[];
cursor?: string;
};
```
### StorePutRequest
```ts
type StorePutRequest = {
value?: unknown;
ttlS?: number; // minimum 1
};
```
### StorePutResponse
```ts
type StorePutResponse = {
version: number; // minimum 1
};
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# PKU harness Cloud (/en/docs/cloud)
> PKU harness Cloud is an optional backend standard for plugins, covering storage, relay channels, push, files and server functions. The official instance, a self-hosted server or any conforming server can provide it. Data is end-to-end encrypted by default, and the cloud holds no campus identity.
Most plugins run only on the phone, but a few features need a server. For example, a plugin may
share a campus-card payment code live with a friend who doesn't have the app, or keep a small
piece of data in sync across devices. PKU harness Cloud ("the cloud" below) is the backend
standard for these features. It defines which endpoints a server provides and how the app and
plugins use them.
We designed the cloud so that you can use a server without having to trust it. The sections
below explain how.
## Design principles
Data is encrypted before it leaves the phone. This is called end-to-end (E2E) encryption: only
the sender and the receiver hold the keys, and the server forwards and stores ciphertext that it
can't decrypt.
The cloud holds no campus identity. The server knows a device only by a keypair that the device
generates. It never learns your student ID or name, and it never receives your campus password
or login sessions.
Several providers can serve the same protocol. You can use the official instance, a reference
server you deploy yourself, or any server URL that implements the protocol. Plugins call the
same interface in every case and can't tell which provider they are talking to.
The app keeps plugins apart. We call the app the _host_, because it runs the plugins. The host
gives each plugin a cloud client bound to that plugin alone, with its own tokens, its own storage
space and its own consent from you.
The AI assistant uses the cloud only through the tools that plugins provide. It never sees cloud
credentials, tokens or share links.
The official cloud instance is not deployed yet. Until it is, enter the URL of a self-hosted
server in Settings → Cloud if you want to use the cloud.
These parts are done: protocol v1 (`@pku-harness/cloud-protocol`), the SDK types, the
reference server (`packages/cloud-server`) and the cloud client in the app. The client handles
device identity, per-plugin binding and consent, end-to-end encrypted store and channels, and
a connection test in Settings → Cloud. The first plugin that uses the cloud is `card-share`,
which shares a campus-card payment code live, end-to-end encrypted. The push service (`push`)
isn't finished, and the reference server answers `501 unsupported` for it.
When the official instance launches, it will stay small on purpose. It will offer the relay for
live shares (channels and mailbox, where the server only forwards ciphertext it can't read), a
small end-to-end encrypted store and the static viewer pages. Plugin
[server functions](#server-functions) exist in the reference server, but they are off on the
official server.
## The three parts of the cloud
The cloud has three parts. The service definition consists of SDK types and a versioned HTTP
protocol. Service providers are whoever runs a server. Consumers are the plugins that call the
cloud.
| Part | What | Where |
| --- | --- | --- |
| Service definition | SDK types (`CloudManifest`, `PluginCloud`) and the HTTP protocol (`/v1`) | `@pku-harness/sdk` (`cloud.ts`), `@pku-harness/cloud-protocol`, [API reference](/en/docs/cloud/api/meta) |
| Service providers | The official instance, the open-source reference server (self-hosted), any conforming URL | [Providers and self-hosting](/en/docs/cloud/providers) |
| Consumers | Plugins, through `ctx.cloud` or `props.cloud` | [Plugin guide](/en/docs/cloud/plugin-guide) |
## Services the cloud offers
The protocol defines five services. A provider may offer only some of them.
`store` is a key-value store. Each plugin on each device gets its own space, keyed by
`(device, plugin)`. It supports versions (`If-Match`) and expiry times (time to live, TTL). When
a plugin declares `e2e`, values are encrypted on the phone before they are uploaded.
`channels` are relay channels for live sharing. A device opens a channel, viewers join with a
share link, and the server relays data frames between them. Channels are always end-to-end
encrypted, and each viewer's key is derived from the secret in the share link.
`push` handles push tokens and scheduled pushes. It is still planned, and the official instance
won't offer it. Push payloads are encrypted, so the server only learns when to send.
`files` is file storage. It is also planned.
`functions` are server functions that a plugin declares in the `server:` section of its
`PLUGIN.md` (see [below](#server-functions)). They are off on the official server. A server
function must be declared in advance and approved by you, and it is the one place in the cloud
where plaintext may be processed.
The app asks a provider which services it offers with `GET /v1/meta`. If a plugin lists a
service in `uses` that the provider doesn't offer, the app marks the plugin unavailable and says
why.
## How a request flows
Take a plugin writing one value to the store as an example. The plugin calls
`ctx.cloud.store.put`. The host first checks consent and the `uses` and `e2e` fields of the
plugin's manifest. If the plugin declared `e2e`, the host encrypts the value with
XChaCha20-Poly1305. The host then signs a challenge with the device's Ed25519 key to get a token
valid for 10 minutes, and sends the request with that token to the provider. The provider reads
the device and plugin from the token, picks the storage space, and stores the ciphertext.
```
plugin ──ctx.cloud.store.put(key, value)──► host (bound client for this plugin)
│ checks consent, manifest `uses`, `e2e`
│ seals value (XChaCha20-Poly1305) when e2e
│ mints/refreshes a 10-min token (Ed25519-signed challenge)
▼
provider PUT /v1/store/ Authorization: Bearer
│ namespace = (deviceId, plugin) from the token
▼
stores ciphertext
```
## Where server code runs
When a plugin needs a server, there are three options. We suggest considering them in this
order.
1. Standard services: store, channels, mailbox, push and files. Every conforming server
implements them, and no plugin code runs on the server. Use this option whenever it can do
the job.
2. Plugin server functions, written in the `server:` section of a `PLUGIN.md`. The server runs
them with the same interpreter as the app. They are off on the official server.
3. External backends: any URL that implements the protocol. The plugin declares it in the
manifest (`backend: { kind: 'external', url }`), and you must approve it before it is used.
## Server functions
Keep your plugin's work on the phone, or run your own backend. The design document,
[`docs/CLOUD-FUNCTIONS.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/CLOUD-FUNCTIONS.md),
is on hold. What already exists stays, but collections, schedules, push, groups and webhooks
are not being built.
A plugin document can declare server functions. They use the same steps and expressions as the
plugin's tools, but they run on the server. Here is an example:
```yaml title="PLUGIN.md (frontmatter excerpt)"
cloud:
disclosure: { zh: ['你输入的名字会发到云端服务器。'], en: ['The name you enter is sent to the cloud server.'] }
server:
network: [api.example.org] # hosts server steps may call (HTTPS)
secrets: [API_KEY] # held by the operator; usable only in request templates
functions:
stats:
effect: read
access: public # user (default) | public served today; peer, group, webhook are not
cache: 30s
steps:
- let: c
request: { url: 'https://api.example.org/v1/count', headers: { authorization: 'Bearer ${secrets.API_KEY}' } }
as: json
return: "{ count: c.count }"
```
Keep these rules in mind:
- A `server:` section implies `cloud.uses: [functions]`, and it requires `cloud.disclosure`.
- Server steps have their own set of host abilities: a per-user server key-value store
(`kv.get/set/delete`), plus `crypto.sha256`, `crypto.hmacSha256` (which takes a secret by
name) and `crypto.randomId`. Server steps can't reach campus services. `caller.id` is a
pseudonym of the device, different for each plugin.
- The phone calls a function through the `cloud.call { fn, input }` ability. A widget can bind
`{ fn }` as well as `{ tool }`.
- The token records the plugin document version you consented to (link plus content hash), and
the server runs only that version. Whether it runs at all also depends on the operator's
policy, `CLOUD_FN_POLICY`, which is `verified`, `allowlist` or `open`. The reference server's
README lists the environment variables.
## Next
- [Plugin guide](/en/docs/cloud/plugin-guide): declare `cloud`, get consent, use `store` and `channels`.
- [Security](/en/docs/cloud/security): device keys, proof of work, tokens, envelopes and share links.
- [Providers and self-hosting](/en/docs/cloud/providers): choose a provider and run the reference server.
- [`@pku-harness/cloud-protocol`](/en/docs/cloud/protocol-package): schemas and crypto for servers and viewer pages.
- [API reference](/en/docs/cloud/api/meta): every endpoint, generated from the OpenAPI 3.1 spec.
# Plugin guide (/en/docs/cloud/plugin-guide)
> Use PKU harness Cloud from a bundled plugin. Declare the cloud manifest and disclosure, handle consent and availability, use the store and relay channels, and show share links safely.
This page explains how a bundled plugin (a TypeScript plugin built into the app) uses the cloud.
The running example is the `card-share` plugin, which shares a campus-card payment code live
with a friend. The steps follow the order in which you would build a plugin: declare the cloud
in the manifest, handle consent and availability, then use the store or channels.
## 1. Declare the cloud in the manifest
In the `cloud` field of `definePlugin`, a plugin states which cloud services it uses, whether
its data is end-to-end (E2E) encrypted, and what data leaves the device. The host (the app that
runs the plugin) limits the plugin's calls to this declaration and shows its disclosure to the
user.
```ts
import { definePlugin } from '@pku-harness/sdk';
export const cardSharePlugin = definePlugin({
id: 'card-share',
name: '付款码共享',
description: '把校园卡付款码实时分享给朋友,端到端加密。',
version: '0.1.0',
requires: ['campus-session', 'network', 'cloud'],
cloud: {
protocol: 1,
uses: ['channels'],
e2e: true,
pages: ['view'],
disclosure: {
zh: ['分享期间,加密后的付款码经云服务转发给你分享的人;服务器无法解密', '服务器能看到:分享的开始/结束时间、查看者的 IP'],
en: ['While sharing, encrypted payment codes are relayed to the person you shared with; the server can’t decrypt them', 'The server sees when a share starts and ends, and the viewer’s IP address'],
},
},
defaultModes: { read: 'auto', write: 'auto', money: 'ask' },
scopes: ['bdcard.pku.edu.cn'],
tools: { /* … */ },
});
```
The fields follow these rules:
| Field | Rule |
| --- | --- |
| `protocol` | Always `1` |
| `uses` | Only the services you call. The host refuses calls to any other service. |
| `disclosure.zh` | Required when `uses` isn't empty; see below. |
| `e2e` | `true` makes the host refuse plaintext store writes. Turn it on whenever you can. |
| `optional` | `true` if the plugin also works without the cloud. |
| `pages` | Static viewer pages for people without the app, built from `plugins//web` and served at `/w//`. |
| `functions`, `backend` | Server functions, or an external backend URL. An external backend is shown to the user before consent. |
`disclosure` is the data notice that users read. In plain words, say exactly what leaves the
device and what the server can see, one fact per line. If you also write `en`, it must translate
the Chinese line for line.
If you set `optional: true`, don't list `'cloud'` in `requires`, and check `ctx.cloud?.available`
before using it. Server functions are off on the official server; see [below](#server-functions).
## 2. Get the user's consent
Every plugin needs the user's consent on its own. The plugin's page in Settings has a **Cloud**
section. It shows the provider host, your disclosure lines, an E2E badge, the services used and
usage against quota. It also has an **Allow cloud** switch and a **Clear this plugin's cloud
data** button.
Until the user allows it, every cloud call from the plugin throws
`CloudError('consent_required')`. In a screen, you can ask first with `cloud.requestConsent()`.
It shows the host's consent sheet and resolves `true` if the user agrees or `false` if they
decline. If the user agreed earlier, it resolves `true` right away.
```tsx
const start = async () => {
if (!(await props.cloud?.requestConsent())) return; // declined, or no cloud on this host
const channel = await props.cloud!.channels.open({ ttlS: 30 * 60, maxPeers: 1, page: 'view' });
// …
};
```
When the AI assistant calls a plugin tool, the host never shows the consent sheet. A tool
doesn't need special handling: let the error through, and the assistant tells the user where to
allow the cloud. If you want to return a clearer result, catch the error like this:
```ts
import { isCloudError } from '@pku-harness/sdk';
try {
await ctx.cloud?.store.put('prefs', prefs);
} catch (e) {
if (isCloudError(e, 'consent_required')) return { status: 'cloud_consent_required' };
if (isCloudError(e, 'offline')) throw new Error('网络不可用,稍后再试');
throw e;
}
```
## 3. Check that the cloud is available
The user may not have configured a provider or given consent, or the provider may be
unreachable. In each case `ctx.cloud?.available` is `false`, and the plugin should return a clear
result instead of making the call.
```ts
const cloud = ctx.cloud;
if (!cloud?.available) {
// provider not configured, no consent, or unreachable
return { status: 'cloud_unavailable', provider: cloud?.status().provider ?? 'off' };
}
```
`status()` returns `{ provider: 'official' | 'custom' | 'off', baseURL?, consented, online }`.
When the phone is offline, operations behave differently. Store reads fall back to a local
mirror. Writes and function calls fail at once with `offline`; the host doesn't queue them to
send silently later. Channels reconnect on their own with backoff.
## 4. Use the store
The store is a key-value store for small pieces of settings or state. This example reads a
counter, adds one and writes it back:
```ts
const cur = await ctx.cloud!.store.get<{ count: number }>('counter');
await ctx.cloud!.store.put('counter', { count: (cur?.value.count ?? 0) + 1 }, { ifVersion: cur?.version ?? 0 });
const keys = await ctx.cloud!.store.list('history/');
```
- The provider namespaces keys by `(device, plugin)`, so no other plugin can read them.
- `ifVersion` gives you optimistic concurrency: a write succeeds only if the current version
matches the one you pass. `0` means the key must not exist yet. On a mismatch the server
answers `409 conflict`, which the host throws as `CloudError('protocol')`.
- `ttlS` sets how long a value lives, in seconds. The value is deleted when it expires.
- Default quotas are 1 MB and 1,000 keys per plugin per device, and 64 KB per value.
## 5. Use relay channels
A channel relays end-to-end encrypted data frames between the owner's phone and the people who
open a share link. The provider forwards ciphertext and controls who may join. Only people who
hold the share link can decrypt.
```ts
const channel = await cloud.channels.open({
ttlS: 30 * 60, // required, 60 s – 24 h
maxPeers: 1, // first joiner claims the seat; a forwarded link is useless
page: 'view', // viewer page for people without the app
meta: { nameHint: '王_明', pinRequired: true }, // sealed; viewers read it before joining
});
const off = channel.on('message', (e) => {
if (e.type !== 'message') return;
const msg = frameSchema.safeParse(e.msg); // always validate peer frames
if (!msg.success) return;
// …
});
channel.on('join', (e) => e.type === 'join' && void channel.send(e.peerId, { t: 'hello' }));
// later
await channel.extend(15 * 60);
await channel.close();
off();
```
Channels also offer these methods:
- `send(peerId | '*', msg)` encrypts the message for each peer and sends it. A frame is at most
4 KB, and you can send at most 60 frames per minute.
- `mailbox.put(slot, msg, ttlS)` leaves an encrypted blob that peers can read while you're
offline.
- `kick(peerId)` removes a viewer and frees the seat, so the same link then works for a new
viewer.
- `channels.get(id)` and `list()` still work after the app restarts, because the channel keys
are kept in the system keystore.
- For long shares on Android, keep the app running with
`services.ongoing.start({ title, until, onStop })`. `services.ongoing` exists only on Android.
### Showing the share link
`channel.link` is a `SecretLink` object. Plugin code can't read the URL inside it, and
serialising it only gives `"[secret link]"`. To hand the link to the user, use the components
the host provides:
```tsx
const { QRCode, Button } = host.components;