# Write a plugin with the AI (/en/docs/build/ai-authoring) > Ask the assistant in the app to write a PLUGIN.md plugin with you. It reads the language reference, validates the draft, tries read-only tools against the live service, and opens the usual review sheet, where you decide whether to install. A plugin document is plain text, so the AI assistant in the app can write one with you. For example, you can ask for "a plugin for the gym booking site". The result is a `PLUGIN.md` folder that you review and then install, or discard. You need only the app and a configured AI model. No computer is required, although you will want one later if you publish the plugin. The assistant does this work through the tools of a built-in plugin called **Plugins** (id `plugins`). None of these tools can install anything without your confirmation. ## The tools The assistant uses five tools. The first four only read, and the last one opens a sheet for you. **`plugins.reference`** (read) returns the whole plugin language on one page: the manifest, steps, expressions, the standard library, host abilities, limits and an example. The assistant reads it when it is about to write or edit a plugin. It is not part of every prompt, so it takes up no space in other conversations. **`plugins.validate`** (read) checks a draft folder with the same loader the app uses. The draft is passed as `{ files: { "PLUGIN.md": "…", "tools/x.yaml": "…" } }`. The tool returns `{ ok: true, id, version, tools, widgets, campus, network, abilities }` when the draft is valid. Otherwise it returns `{ ok: false, issues }`, and each issue names the file and step where the problem is. **`plugins.tryTool`** (read) runs one **read** tool of a draft for real, without installing the draft. It sends live requests, uses your campus sessions for the services the draft declares, and gives the tool a throwaway store. Tools with the `write`, `money` or `public` effect can't be tried this way. The results are treated as untrusted data. **`plugins.source`** (read) returns the files of an installed plugin document, so the assistant can explain, fix or copy it. Built-in TypeScript plugins have no document, so this tool doesn't work for them. **`plugins.install`** (write) opens the app's review sheet for a link (a GitHub folder or a `PLUGIN.md` URL) or for a draft (`{ files }`). It only opens the sheet. You make the decision there. The Plugins plugin asks before it runs `write` tools by default. Unless you have changed that mode, the assistant therefore also checks with you before it opens the sheet. ## How a plugin is written ### Describe what you want Say what the plugin should do. If you know which page or API it should use, say that too. If it is a campus service, mention it: the plugin will call the service with your signed-in session and will never see your password. ### The assistant drafts and validates These tools are not offered when a chat starts: the assistant first loads the authoring tools of the `plugins` plugin with `plugins.use`. Then it reads `plugins.reference`, writes `PLUGIN.md` and the tool files. It calls `plugins.validate` after each change and keeps fixing the draft until no issues are left. ### It tries the read tools `plugins.tryTool` runs a read tool against the real service. You see actual results before anything is installed, which tells you whether the plugin really works. ### You review and install `plugins.install` opens the same review sheet you see for an install link. It shows whether the plugin is official or community; the campus services, websites and app features it can reach; and every tool with its effect. A draft written in chat counts as a community plugin. Nothing is installed until you tap *Install*. ## Changing an installed plugin To change an installed plugin document, ask the assistant to fix or extend it. It reads the files with `plugins.source`, edits them, validates the result, and opens the review sheet again with the new version. If you paste a plugin link in the chat, the assistant opens the review sheet for it with `plugins.install { link }`. ## Share what you made The assistant's draft is an ordinary plugin folder. To share it, or to let it update from a link, put the folder in a public GitHub repository. The folder's URL becomes its install link (see [Your first plugin](/en/docs/build/quickstart#share-it)). Before you publish, run the [author CLI](/en/docs/build/declarative-plugins#test) on a computer and add recorded tests. ## Safety - The assistant never installs anything by itself. `plugins.install` only opens the review sheet. - `plugins.tryTool` runs only read tools, reaches only what the draft declares, and keeps nothing. - An installed draft follows the same rules as any community plugin. Its `write`, `money` and `public` tools ask first, and its results reach the AI as data, never as instructions. The language itself is covered in [Declarative plugins](/en/docs/build/declarative-plugins). [Permissions](/en/docs/concepts/permissions) explains how the permission modes work. # Bundled plugin (/en/docs/build/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/` 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. 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](/en/docs/build/declarative-plugins) 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](/en/docs/concepts/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 ```json title="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. ```ts title="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 { return (await kv.get(KEY)) ?? []; } export async function saveSessions(kv: KV, list: Session[]): Promise { 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. ```ts title="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: | 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 `title`s | 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/` 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: ```tsx title="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('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 ( {active ? s.running(active.label) : s.idle}