PKU harness
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.

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:

FieldRule
protocolAlways 1
usesOnly the services you call. The host refuses calls to any other service.
disclosure.zhRequired when uses isn't empty; see below.
e2etrue makes the host refuse plaintext store writes. Turn it on whenever you can.
optionaltrue if the plugin also works without the cloud.
pagesStatic viewer pages for people without the app, built from plugins/<id>/web and served at /w/<plugin>/<page>.
functions, backendServer 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.

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.
  • 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.

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.

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.

On this page