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/<id> 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.
Most campus integrations are documents now
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 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).
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
{
"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.
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<Session[]> {
return (await kv.get<Session[]>(KEY)) ?? [];
}
export async function saveSessions(kv: KV, list: Session[]): Promise<void> {
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.
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 titles | 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/<screen> 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:
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<Today>('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 (
<Screen title={s.today}>
<Column gap="lg">
<Stat label={s.today} value={data.value?.minutes ?? null} suffix={host.useLocale() === 'en' ? ' min' : ' 分钟'} icon="clock" />
<Text tone="muted">{active ? s.running(active.label) : s.idle}</Text>
<Button title={active ? s.stop : s.start} variant={active ? 'secondary' : 'primary'} loading={data.fetching} onPress={() => void toggle()} />
</Column>
</Screen>
);
}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<Today>('focus.today', {}, { ttlMinutes: 1 });
if (data.status === 'loading') return <Skeleton width="50%" height={28} />;
if (!data.value) return <Text tone="muted">{data.error ?? ''}</Text>;
return (
<View style={{ gap: t.spacing.xs }}>
{size === 'sm' ? (
<Text variant="micro" tone="faint">
{s.today}
</Text>
) : null}
<Text variant={size === 'lg' ? 'numberXL' : 'numberL'}>{s.minutes(data.value.minutes)}</Text>
<Text variant="caption" tone={data.value.running ? 'accent' : 'muted'} numberOfLines={1}>
{data.value.running ? s.running(data.value.running.label) : s.idle}
</Text>
</View>
);
}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<Strings>({
'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.
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:
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:
{
"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):
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 existRun 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
git clone --recurse-submodules https://github.com/pkuinfo/pku-harness.git
cd pku-harness
bun installWork 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:
bun run android # dev build on an emulator or device
# or, with a dev build installed:
bunx expo start --dev-clientMetro, 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. 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,reactandreact-native, plus your own files andplugins/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, i18n rules and capability rules.
- MUST load widget and screen data with
host.useToolData. Don't poll. - SHOULD keep
instructionsandcontextshort, because they cost tokens on every request.
Quickstart: 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.
Tools and schemas
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.