cloud
The plugin side of PKU harness Cloud. CloudManifest, PluginCloud (store, channels, push, functions), Channel and its events, SecretLink, CloudStatus, CloudError and defineCloudFunction.
PKU harness Cloud is a set of optional cloud services. A plugin can use it to keep a small amount of data on a server, or to share content live through a relay with people who open a share link. This module defines the plugin side of it. A plugin declares the services it needs in its manifest and then calls them through a client that the host provides.
Source: packages/sdk/src/cloud.ts. Guide: Cloud → plugin guide. Wire
protocol: @pku-harness/cloud-protocol and the
API reference.
What the host keeps for your plugin
A plugin declares the services it uses in PluginDef.cloud and gets a client bound to itself
(ctx.cloud, props.cloud). The host keeps the device identity, tokens and keys. It checks that
the user has given consent and that each call matches the plugin's manifest, and it talks to the
provider the user picked. The provider can be the official server, a self-hosted server or any URL.
Your plugin never sees tokens, keys or provider credentials, and the agent never sees share links.
Host support
The app implements this client. ctx.cloud stays typed as optional (PluginCloud | undefined)
for hosts without cloud support, so check ctx.cloud?.available before using it.
push isn't offered yet, and its calls fail with not_configured. The official cloud instance
isn't deployed yet. Once it is, it will run only the relay and a small encrypted store, with
server functions switched off. Server functions also read their input in plaintext, so the host
refuses functions.invoke from a plugin that declares end-to-end encryption (e2e). Keep the
work on the phone when you can, and use your own backend when you really need a server.
CloudManifest
CloudManifest is the manifest a plugin declares in PluginDef.cloud. It lists the services the
plugin uses, what data leaves the device, and whether that data is end-to-end encrypted.
type CloudService = 'store' | 'channels' | 'push' | 'files' | 'functions';
interface CloudManifest {
protocol: 1;
uses: CloudService[];
/** Plain-language lines shown in the consent sheet: exactly what leaves the device. */
disclosure: { zh: string[]; en?: string[] };
/** All store/channel payloads are client-encrypted; the host refuses plaintext writes when true. */
e2e: boolean;
/** Plugin still works without cloud; features check ctx.cloud.available. Else requires 'cloud'. */
optional?: boolean;
/** Server functions: run on the user's provider (code in plugins/<id>/server) or an external URL. */
functions?: Record<string, CloudFunctionDecl>;
backend?: { kind: 'provider' } | { kind: 'external'; url: string };
/** Static pages from plugins/<id>/web served at /w/<plugin>/<page> (viewers without the app). */
pages?: string[];
}
interface CloudFunctionDecl<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> {
title: string;
input: I;
output: O;
version: number;
timeoutMs?: number;
}
function defineCloudFunction<I extends z.ZodType, O extends z.ZodType>(d: CloudFunctionDecl<I, O>): CloudFunctionDecl<I, O>;PluginCloud
PluginCloud is the client your plugin gets, and CloudStatus describes its current state.
interface PluginCloud {
/** Provider configured, consent given and reachable (offline-tolerant reads aside). */
readonly available: boolean;
status(): CloudStatus;
/**
* Ask the user to allow this plugin to use the cloud (the host's consent sheet; resolves at once
* when already allowed). False when they decline. Screens call it before opening a channel;
* agent tool calls never prompt and get `CloudError('consent_required')` instead.
*/
requestConsent(): Promise<boolean>;
/** Key-value store namespaced to (device, plugin) by the provider. Values are sealed when `e2e`. */
store: {
get<T>(key: string): Promise<{ value: T; version: number } | undefined>;
put<T>(key: string, value: T, opts?: { ifVersion?: number; ttlS?: number }): Promise<{ version: number }>;
delete(key: string): Promise<void>;
list(prefix?: string): Promise<{ key: string; version: number; updatedAt: number }[]>;
};
/** End-to-end encrypted relay channels (live sharing with people who open a link). */
channels: {
/** `meta` is sealed with the link key; viewers read it before joining. */
open(opts: { ttlS: number; maxPeers?: number; page?: string; meta?: unknown }): Promise<Channel>;
/** Survives app restarts (keys are kept in secure storage). */
get(id: string): Channel | undefined;
list(): Channel[];
};
/** Stage 6. */
push: {
schedule(at: number, payload: unknown): Promise<string>;
cancel(id: string): Promise<void>;
};
/** Server functions declared in `CloudManifest.functions` (stage 6). */
functions: {
invoke<N extends string>(name: N, input: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
};
}
interface CloudStatus {
provider: 'official' | 'custom' | 'off';
baseURL?: string;
/** The user allowed this plugin to use the cloud. */
consented: boolean;
online: boolean;
}Channel
A channel is for live sharing. Your plugin opens a channel and gives someone its share link. Each person who opens the link becomes a peer of the channel.
interface Channel {
id: string;
expiresAt: number;
link: SecretLink;
peers(): { id: string; joinedAt: number }[];
/** Subscribe; returns an unsubscribe function. */
on(ev: ChannelEventType, fn: (e: ChannelEvent) => void): () => void;
/** Sealed per peer; '*' = every connected peer. */
send(peerId: string | '*', msg: unknown): Promise<void>;
/** Sealed blobs readable by peers while the owner is offline (batch mode). */
mailbox: {
put(slot: string, msg: unknown, ttlS: number): Promise<void>;
clear(): Promise<void>;
};
/** Drop a peer and free its seat (a new viewer can join with the same link). */
kick(peerId: string): Promise<void>;
extend(ttlS: number): Promise<void>;
close(): Promise<void>;
}
type ChannelEventType = 'join' | 'leave' | 'message' | 'closed';
type ChannelCloseReason = 'owner' | 'expired' | 'revoked' | 'locked' | 'server';
type ChannelEvent =
| { type: 'join'; peerId: string; at: number }
| { type: 'leave'; peerId: string; at: number }
/** A peer's frame, already decrypted (validate it with zod before use). */
| { type: 'message'; peerId: string; msg: unknown }
| { type: 'closed'; reason: ChannelCloseReason };SecretLink
/**
* An opaque share link (`https://<provider>/s/<id>#k=…`). Plugins can't read the URL: show it with
* `host.shareLink(link)` or `<QRCode link={link} />`. `JSON.stringify(link)` gives "[secret link]".
*/
interface SecretLink {
readonly kind: 'secret-link';
readonly channelId: string;
toJSON(): string;
}The host's toString also prints "[secret link]", so a link can't leak into tool results or logs.
Errors
type CloudErrorCode = 'not_configured' | 'consent_required' | 'offline' | 'rate_limited' | 'quota' | 'gone' | 'protocol' | 'server';
const CLOUD_ERROR_CODES: readonly CloudErrorCode[];
class CloudError extends Error {
readonly code: CloudErrorCode;
/** Set for 'rate_limited' (from Retry-After). */
readonly retryAfterS?: number;
constructor(code: CloudErrorCode, message: string, retryAfterS?: number);
}
function isCloudError(e: unknown, code?: CloudErrorCode): e is CloudError;The error codes mean the following:
| Code | When |
|---|---|
not_configured | No provider configured, or the service isn't available (for example push) |
consent_required | The user hasn't allowed this plugin to use the cloud |
offline | No network. Writes and functions fail right away; there is no silent outbox |
rate_limited | The provider answered 429; retryAfterS is set |
quota | Store or channel quota exceeded |
gone | The channel was closed or has expired |
protocol | The provider's answer doesn't match protocol v1, or the call breaks the manifest (an undeclared service, plaintext from an e2e plugin) |
server | Any other provider error |
campus
Types for signed-in campus requests through the host (CampusService, CampusRequest, CampusResponse, multipart uploads, picked files), the sign-in status and the 手机令牌 (IAAA authenticator) code.
timetable
The Course and Semester types for timetable data, and pure helpers that parse weeks expressions, find the teaching week, list the classes on a date, report class status and build class reminder triggers.