HostServices
Signatures and behaviour of every host service a bundled plugin gets as ctx.services or props.services, including the settings registry and attached files, plus the job, widget catalog and ongoing-notification types.
A plugin can't call the app's internal code. When it needs to open a browser, pick a file, send a
campus request or create a reminder, it calls a service that the host provides. Tools get these
services as ctx.services, and screens and widgets get them as props.services. This page lists
the signature and behaviour of each service.
Source: packages/sdk/src/plugin.ts. Concepts: Host services.
interface HostServices {
phone: PhoneService;
settings: SettingsService;
browser: {
open(url: string): Promise<{ opened: true }>;
};
files: FilesService & {
pick(opts?: { types?: string[]; multiple?: boolean }): Promise<PickedFile[]>;
};
crypto: {
sha256(text: string): Promise<string>;
};
registry: {
plugins(): PluginInfo[];
tool(id: string): ToolInfo | undefined;
};
docs?: {
review(input: { link?: string; files?: Record<string, string> }): Promise<{ opened: true }>;
source(id: string): { link: string | null; version: string; files: Record<string, string> } | null;
};
remote: {
install(input: { url: string; name?: string; token?: string }): Promise<{ id: string; name: string; tools: { name: string; effect: ToolEffect }[] }>;
uninstall(id: string): Promise<void>;
has(id: string): boolean;
};
genui: {
describeCatalog(): string;
checkSpec(spec: unknown): SpecCheck;
};
notify: {
readiness(): Promise<NotifyReadiness>;
fix(kind: NotifyFixKind): Promise<NotifyReadiness>;
};
sms: {
awaitCode(): Promise<string | null>;
};
scheduler: {
create(input: JobInput): Promise<Job>;
update(id: string, patch: JobPatch): Promise<Job>;
remove(id: string): Promise<void>;
get(id: string): Job | undefined;
list(): Job[];
runCheck(id: string): Promise<CheckResult>;
};
widgets: {
list(): Widget[];
get(id: string): Widget | undefined;
newId(): string;
put(widget: Widget): Promise<void>;
remove(id: string): Promise<void>;
reorder(ids: string[]): Promise<void>;
preview(widget: Widget): Promise<Record<string, unknown>>;
resolve(widget: Widget, force?: boolean): Promise<Record<string, unknown>>;
catalog(): WidgetCatalogEntry[];
};
todos: {
list(): Todo[];
get(id: string): Todo | undefined;
add(input: TodoInput, source: JobSource): Promise<Todo>;
update(id: string, patch: Partial<TodoInput>, source: JobSource): Promise<Todo>;
setDone(id: string, done: boolean): Promise<Todo>;
remove(id: string): Promise<void>;
};
campus: {
status(): CampusStatus;
request(service: CampusService, req: CampusRequest): Promise<CampusResponse>;
otp(): Promise<OtpCode | null>;
otpStatus(): Promise<OtpStatus>;
otpBindStart(): Promise<{ sentTo: string | null }>;
otpBindFinish(smsCode: string): Promise<OtpStatus>;
otpUnbindLocal(): Promise<void>;
openSignedIn(service: CampusService): Promise<{ opened: true }>;
};
ongoing?: {
start(opts: OngoingOptions): OngoingHandle;
};
timetable: {
get(): { courses: Course[]; semester: Semester | null; reminderMinutes: number | null };
newId(): string;
upsert(course: Course): Promise<void>;
remove(id: string): Promise<void>;
setReminderMinutes(minutes: number | null): Promise<void>;
reloadSemester(): Promise<void>;
};
}Behaviour notes
The notes below describe how each member behaves and what to watch for when you call it.
-
phonedrives Copilot's separate screen (PhoneServiceinpackages/sdk/src/phone.ts). It is meant for the bundled Copilot plugin, needs thephone-controlcapability, and isn't covered in this reference. -
settingslists and changes every app and plugin setting; see Settings. -
files.list/read/searchreach the documents attached in the current chat; see Attached files. -
browser.openopens the page in a system browser tab (Custom Tabs on Android, SFSafariViewController on iOS). App links on that page (weixin://,alipays://) open those apps, so a checkout page can lead on to WeChat Pay or Alipay. The call resolves as soon as the page is open, so it can't tell you whether a payment went through. When the user comes back to the app, read the result again, for example the balance. -
files.pickcan only be used by the user. Call it from a screen in response to a tap, never from an agent tool.typesare MIME types and may use wildcards, as in['application/pdf', 'image/*']. It resolves to[]if the user cancels. The picked files are copied into the app cache, and you get references to them (URIs valid for this app session), not their bytes. -
crypto.sha256returns the SHA-256 of the UTF-8 text as lowercase hex. Plugins have no crypto library of their own, so use it for request signatures. -
registryis a read-only view of the installed plugins and tools.tool(id)also resolves legacy ids. -
docsmanages document (PLUGIN.md) plugins and is used by thepluginsplugin.reviewopens the app's permission sheet for a link or a set of files and resolves once the sheet is shown; the user then decides in the sheet.source(id)returns an installed document plugin's files, so you can read or fork it, and returnsnullfor any other plugin. This member is optional, so check that it exists before you use it. -
remoteinstalls and removes remote plugins, which connect through the Model Context Protocol (MCP). It is used by thepluginsplugin. -
genui:describeCatalog()returns the component catalog as text that you can put in a tool description.checkSpec(spec)validates a spec against that catalog. -
notify.readiness/fix:readinesstells you whether reminders can fire on time. It checks the notification permission, exact alarms and battery optimisation. Callfixonly from a tap. A system settings screen may return before the user changes anything, so readreadinessagain when the app comes back to the foreground. -
sms.awaitCoderesolves with the code from the next incoming SMS once the user allows the app to read that one message (Android SMS User Consent). It returnsnullwhen the feature is unavailable, when the user denies it, when no code can be found, or after 5 minutes. Call it right after you ask the service to send the SMS. -
scheduler:Trigger,ActionandJobare defined on the scheduler page.runCheckruns a check job immediately. -
widgets.preview/resolvefill in a widget's bindings and return the state its spec will see.previewalways fetches fresh data.resolveuses a cache whose entries expire after their time to live (TTL), unless you passforce. -
widgets.cataloglists the native widgets contributed by enabled plugins. -
todos: thesourceargument records who made the change. -
campus.requestneeds thecampus-sessioncapability. See campus. -
campus.otpreturns the current 手机令牌 (mobile authenticator) code if the user has bound the app's built-in authenticator, andnullotherwise. A plugin never gets the secret itself. -
campus.otpStatus,otpBindStart,otpBindFinish,otpUnbindLocalmanage the authenticator. Only the screens of the bundled authenticator plugin should call them. -
campus.openSignedInopens the service in the system browser, already signed in through the service's own sign-in hand-off. The host builds the URL, so the plugin never sees the session token. It rejects if the service has no such hand-off. -
ongoingexists only when the device has theongoingcapability (Android). Start it from a user action and stop it when you're done. -
timetable.reloadSemestermakes the host read the semester again after the calendar plugin has saved it.
Settings
settings is the host's settings registry: every setting the user can change in the app, in one
list. The Settings screens and the core settings plugin (the agent's way in) both use it, so a
change from either place shows in both. Source: packages/sdk/src/settings.ts.
type SettingValue = string | number | boolean | string[];
interface SettingInfo {
/** `app.theme`, `plugin.copilot.enabled`, `copilot.apps`, … */
key: string;
/** Section it belongs to, for listing (`app`, `ai`, a plugin's name, …). */
group: string;
title: string;
description?: string;
/** `choice` takes one of `options`; `list` is a list of strings. */
kind: 'choice' | 'boolean' | 'text' | 'list';
options?: { value: string; label: string }[];
value: SettingValue | null;
/** A secret or a step that needs the user: never set by the agent; `screen` is where it lives. */
uiOnly?: boolean;
/** App route of the screen that shows this setting. */
screen?: string;
}
interface SettingChange {
key: string;
before: SettingValue | null;
after: SettingValue | null;
}
interface SettingsService {
/** Every setting with its current value. */
list(): Promise<SettingInfo[]>;
/** Apply changes in order. */
change(changes: { key: string; value: SettingValue }[]): Promise<SettingChange[]>;
/** Show the screen of a setting. */
open(key: string): void;
}The keys the app lists today:
| Key | Kind | Values |
|---|---|---|
app.theme | choice | system, light, dark |
app.language | choice | system, zh-Hans, en |
app.timezone | text | auto (follow the phone) or an IANA zone such as Asia/Shanghai |
app.haptics | boolean | |
ai.provider | choice | Provider ids, such as deepseek.api |
ai.model | text | A model id; for a provider with a fixed model list, one of its models |
voice.provider | choice | Speech provider ids, such as aliyun-bailian.realtime |
plugin.<id>.enabled | boolean | Not listed for core plugins |
plugin.<id>.permission.<effect> | choice | auto, ask, off; for plugins with tools |
<plugin>.<name> | from the plugin | Each entry of PluginDef.preferences, e.g. copilot.enabled, copilot.apps, copilot.screenshots |
ai.apiKey, voice.apiKey, campus.account, app.notifications | text, uiOnly | Only the user can set these; open shows their screen |
How change behaves:
- It checks every key first. An unknown key or a
uiOnlykey fails the whole call before anything changes. - It then applies the changes in order. Each value is validated against its entry (an option of a
choice,true/falsefor aboolean, the preference'sinputschema). An invalid value throws anErrorwith a readable message. - It returns
beforeandafterfor each key.
open(key) navigates to the setting's screen, and throws for an unknown key. Call it in response
to the user, for example when they ask to change a uiOnly setting.
A plugin adds its own entries with PluginDef.preferences, not
through this service.
Attached files
Besides pick, files reaches the documents the user attached in the current chat: PDF, Word,
Excel, PowerPoint, audio transcripts and text files. The host extracts their text on the phone. The
user's message carries each one as <attachment name=… kind=… id=… chars=…>, with only the beginning
inline when it is long; these calls page through the rest or search it. Offsets and lengths count
characters of the extracted text. Source: packages/sdk/src/files.ts.
interface FileInfo {
id: string;
name: string;
/** 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'audio' | 'text' | … (host-defined). */
kind: string;
/** Length of the extracted text. */
chars: number;
/** Pages, sheets, slides or seconds, when the kind has them. */
units?: number;
}
interface FileReadResult extends FileInfo {
/** Where this slice starts. */
offset: number;
text: string;
/** Offset to continue from, null at the end. */
next: number | null;
}
interface FileSearchResult {
id: string;
name: string;
matches: { offset: number; snippet: string }[];
}
interface FilesService {
/** Documents attached in the current chat (most recent first). */
list(): Promise<FileInfo[]>;
read(id: string, offset?: number, length?: number): Promise<FileReadResult>;
search(id: string, query: string, max?: number): Promise<FileSearchResult>;
}Document text is third-party content. A tool that returns it must set untrusted: true, as the core
files plugin's read and search do.
Types used above
These types appear in the signatures above and are not defined on other pages.
type JobInput = { title: string; trigger: Trigger; action: Action; source: JobSource; enabled?: boolean; meta?: Record<string, unknown> };
type JobPatch = Partial<Pick<Job, 'title' | 'trigger' | 'action' | 'enabled' | 'meta'>>;
type CheckResult = { notified: boolean; value?: unknown; error?: string };
type ToolInfo = { id: string; pluginId: string; title: string; effect: ToolEffect };
type PluginInfo = {
id: string;
name: string;
kind: 'system' | 'builtin' | 'remote';
enabled: boolean;
modes: Record<ToolEffect, 'auto' | 'ask' | 'off'>;
tools: { id: string; title: string; effect: ToolEffect }[];
};
interface WidgetCatalogEntry {
plugin: string;
pluginName: string;
widget: string;
title: string;
description: string;
sizes: WidgetSize[];
defaultSize: WidgetSize;
/** JSON Schema of the widget's config, if it takes any. */
config?: Record<string, unknown>;
}
interface OngoingOptions {
title: string;
body?: string;
/** When the activity ends (ms epoch): shown as a countdown; the host stops it then. */
until?: number;
/** Label of the notification's stop action (default 停止). */
stopLabel?: string;
/** Called when the user taps stop (or the system ends the service). */
onStop?: () => void;
}
interface OngoingHandle {
update(p: { title?: string; body?: string }): void;
stop(): void;
}Example: pick a file and upload it
import type { HostServices } from '@pku-harness/sdk';
/** Call from a screen's button handler: only the user can pick files. */
export async function pickAndSubmit(services: HostServices, hiddenFields: [string, string][]) {
const [file] = await services.files.pick({ types: ['application/pdf'] });
if (!file) return null; // cancelled
return services.campus.request('blackboard', {
url: '/webapps/assignment/uploadAssignment?action=submit',
method: 'POST',
multipart: {
fields: hiddenFields, // the submission form's hidden fields (nonce, course_id, content_id…)
files: [{ field: 'newFile_LocalFile0', name: file.name, mime: file.mime, uri: file.uri }],
},
});
}This is a shortened version of how the courses plugin submits an assignment. The real flow is
submitAssignment in plugins/courses/src/api.ts, ported from pkucli's crates/course. It first
reads the submission form to get its hidden fields and a fresh nonce, then posts the submission,
and finally reads the assignment page again to confirm the new attempt. The file bytes never pass
through the plugin. The host streams them directly from the picked file's URI.
plugin
Every type you use to define a plugin: definePlugin, defineTool, definePreference, PluginDef, ToolDef, ToolContext, effects, actors, capabilities, KV, turn info, data sources, preferences, model, speech and OCR providers, translations and legacy ids.
ui
Types for plugin UI: the props that screens and widgets receive, HostUI (components, theme, navigation, haptics, live data hooks) and every contribution type (screens, Settings rows, tabs, widgets, commands).