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.
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/<id>/web and served at /w/<plugin>/<page>. |
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.
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.
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:
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.
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:
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. ifVersiongives you optimistic concurrency: a write succeeds only if the current version matches the one you pass.0means the key must not exist yet. On a mismatch the server answers409 conflict, which the host throws asCloudError('protocol').ttlSsets 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.
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)andlist()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.ongoingexists 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:
const { QRCode, Button } = host.components;
<QRCode link={channel.link} size={220} />
<Button title="分享给朋友" onPress={() => void host.shareLink?.(channel.link, { message: '我的付款码' })} />Don't return links from tools, and don't write them to logs. A tool should return
{ status: 'awaiting_user' } and open a screen where the user does the sharing.
6. Add the cloud to your registry entry
The marketplace shows whether a plugin uses the cloud before anyone installs it. For this, copy
the cloud declaration into your entry in registry.json:
{
"id": "card-share",
"cloud": {
"uses": ["channels"],
"e2e": true,
"disclosure": { "zh": ["分享期间,加密后的付款码经云服务转发给你分享的人;服务器无法解密", "服务器能看到:分享的开始/结束时间、查看者的 IP"] }
}
}The registry validator checks three things. disclosure.zh must be present when uses isn't
empty. If en is given, it must translate every line. An external backend.url must use HTTPS.
Server functions
Off on the official server
Keep your plugin's work on the phone, or run your own backend.
A PLUGIN.md plugin can declare server functions in a server: section, using the same steps
and expressions as its tools, and call them through the cloud.call { fn, input } ability.
Widgets can bind { fn } too. The section implies uses: [functions] and requires
disclosure.
The reference server runs access: user functions at POST /v1/fn/<plugin>/<name> and
access: public ones at /v1/p/<plugin>/<name>. It gives functions a server-side key-value
store, crypto helpers and HTTPS requests to the declared hosts. See
Cloud → Server functions and
docs/CLOUD-FUNCTIONS.md
(on hold).
Viewer pages
A viewer page is a web page for people who don't have the app. When someone opens
https://<provider>/s/<id>#k=…, they are redirected to /w/<plugin>/<page>?c=<id>#k=…. The part
after # is the URL fragment. It survives the redirect, and browsers never send it to the
server.
Build the viewer page as a static site in plugins/<id>/web, with Vite + Preact for example.
The page reads k from location.hash and uses
@pku-harness/cloud-protocol to derive the keys, join the
channel and decrypt.
Viewer pages have to work in the WeChat in-app browser, which means two things. First, don't
depend on WebCrypto; the package uses the noble libraries, written in pure JavaScript. Second,
fall back to Server-Sent Events (SSE) when WebSockets aren't available. plugins/card-share/web
is a complete viewer page you can copy from.
PKU harness 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.
Security
How PKU harness Cloud keeps user data unreadable to the server. Device keypairs, proof-of-work registration, short-lived signed plugin tokens, XChaCha20-Poly1305 envelopes, share-link secrets in the URL fragment, claim-once channels and share-link redaction.