PKU harness
Build a plugin

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.

package.json
index.ts
store.ts
store.test.ts
strings.ts
screen.tsx
widget.tsx
index.ts

1. Package

plugins/focus/package.json
{
  "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.

plugins/focus/src/store.ts
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.

plugins/focus/src/index.ts
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:

FieldPurpose
idRegistry id, ^[a-z0-9][a-z0-9-]{1,40}$. Tool ids become focus.start, model names focus__start.
name, description, tool titlesChinese, shown to users. English in i18n.en.
instructionsAdded to the assistant's system prompt and sent with every request, so keep it short. Refer to tools by model name.
dataSourcesRead tools suitable for widgets and checks, with a cache lifetime.
contributes.screensMounted at /p/focus/main (or /m/focus/<screen> for presentation: 'modal').
contributes.settingsA row in Settings that opens a screen.
contributes.widgetsA native dashboard widget. The assistant reads its description.
contributes.commandsA /focus command in the chat composer (here a local action).
toolsThe 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:

plugins/focus/src/screen.tsx
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>
  );
}
plugins/focus/src/widget.tsx
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>
  );
}
plugins/focus/src/strings.ts
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.

plugins/focus/src/store.test.ts
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:

plugins/index.ts
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 exist

Run 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 install

Work 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-client

Metro, 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, react and react-native, plus your own files and plugins/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 instructions and context short, because they cost tokens on every request.

On this page