Declarative plugins (PLUGIN.md)
The plugin language. A plugin is a folder with a PLUGIN.md manifest, instructions for the assistant, tools written as YAML steps, and generative-UI widgets. The app interprets it and installs it from a link, with no code to build and no app release.
This page describes the plugin language: how a plugin folder is laid out, what goes in the manifest, how tools are written as steps and expressions, and how plugins are installed and updated. If you haven't written a plugin yet, do the quickstart first.
Spec v1
The language is specified in
docs/PLUGIN-LANGUAGE.md
(spec version 1) and implemented in the lang/ folder of @pku-harness/sdk, which holds the
schemas, the interpreter, the host abilities and the loader. This page summarises the spec. When
the two disagree, the app follows the SDK's
schema.ts
and abilities.ts.
A declarative plugin is a folder of text files that you can share as a link. It plays the same
role for PKU harness that a SKILL.md folder plays for an AI agent. The app reads the folder and
runs the steps it declares in a small, safe interpreter. The app never downloads or runs plugin
code. As a result, publishing a new plugin or an update needs neither an app release nor a server.
A declarative plugin suits any job that consists of a few requests to campus or public websites plus some extraction. Examples are free classrooms, canteen crowds and opening hours, shuttle timetables, grades, campus events and notices.
Some jobs can't be expressed safely in the language, such as reading an SMS code or adding a to-do. The app provides these as host abilities: features built into the app that any plugin may call once it declares them. If you run your own server, write a remote plugin instead. You can also write a plugin with the AI inside the app.
Official documents
The official campus integrations in
pku-harness-plugins/plugins
are plugin documents, and each comes with recorded tests. They make good worked examples. Each one
shows a different part of the language:
classroomssends public requests in parallel witheach, defines aliases inconstants, and has a dashboard widget.campus-eventsreads Treehole (树洞) calendars throughverify.treehole.gradeshandles a second factor, by SMS or by the authenticator app (手机令牌) of IAAA, PKU's central sign-in system, withverify.treeholeandverify.treeholeSendCode.canteenuses ashared:file, caches results in key-value (KV) storage with a fallback to stale data, and reads HTML tables with merged cells.shuttleuses no network at all. Its timetables are written into the plugin as data, and its widget refreshes every minute.noticesreaches a wildcard host (*.pku.edu.cn) and WeChat articles. It keeps a table of sources inshared:, caches each page in KV with a stale fallback, marks tools that return third-party text withuntrusted: true, and adds a slash command.
The app bundles these documents and updates them from their GitHub links, like any other plugin. Fixing a parser in one of them therefore needs no app release.
Layout
classrooms/
PLUGIN.md # manifest (YAML frontmatter) + instructions for the agent (Markdown)
tools/free.yaml # one tool per file (or inline in the frontmatter)
shared.yaml # optional: constants and functions every tool sees
widgets/overview.json # dashboard widget: generative-UI spec + bindings
screens/…json # full-page generative-UI screens
tests/free.yaml # recorded fixtures for the CLI and CI; never fetched by the app
README.md, assets/ # for humans and the marketplace; ignored by the app- Each folder holds one plugin, and a repository can hold many. In the marketplace, a plugin is
identified as
owner/repo/path. - The app loads only the files named in the frontmatter. Paths are relative and may not contain
... Because the app never lists folders, any static host works: GitHub, a gist or your own server. - Files are UTF-8 text. The files the app loads may add up to at most 512 KB.
The manifest
The manifest is the YAML frontmatter at the top of PLUGIN.md. Here is the manifest of a simplified
classrooms plugin:
---
spec: 1
id: classrooms
name: 空闲教室
description: 查询各教学楼今天、明天、后天的空闲教室。
version: 1.2.0
author: pkuinfo
icon: location
network: [portal.pku.edu.cn]
tools:
free: tools/free.yaml
widgets:
overview: widgets/overview.json
dataSources:
overview: { tool: free, args: { day: today }, ttl: 30m }
i18n:
en:
name: Free classrooms
description: Free classrooms in each teaching building.
tools: { free: { title: Free classrooms, summary: Find free classrooms in a building } }
---
Use `free` when the user asks where to study or which rooms are free. Periods are 1–12
(1 = 08:00). Answer with the building and room, and the free period ranges.Six fields are required: spec: 1, id, name, description (4 to 200 characters), version
(a semantic version such as 1.2.0) and author.
What the plugin declares
Four fields matter most, because students see them on the review sheet before they install.
campus lists the campus services the plugin may call with the user's signed-in session. The
names are card, its, treehole, blackboard, elective, cwfw, cwpay, bdkj, library,
library-space and others. The app signs these requests itself, so the plugin never sees a
password, a cookie or a token.
network lists the public hosts the plugin may call, written as example.com or
*.example.com. Only HTTPS is allowed.
abilities lists the host abilities the plugin uses: verify, otp, todos,
timetable, scheduler, browser, campusPage, sms, files, crypto and cloud.
tools names each tool's file, and each tool declares its own effect: read, write, money or
public.
Optional fields
sharednames a YAML file ofconstantsandfunctionsthat every tool can use. When a tool defines the same name itself, the tool's own definition wins.dataSources,widgetsandscreensadd live data and generative-UI views.settingsandcommandsadd Settings rows and slash commands.defaultModessets the starting permission mode for each effect.i18nholds the English text. It can translate the name, the description, the instructions, and each tool'stitleandsummary(i18n.en.tools.<name>).remote: { mcp, auth }describes a remote MCP server with aPLUGIN.md.clouddeclares the plugin's use of PKU harness Cloud, includingcloud.disclosure, andserverdeclares server functions (below).
You don't need to write requires. It is derived from campus, network, cloud and server.
The manifest reference explains each field and lists what the
marketplace checks.
The instructions
The Markdown body is sent to the model as the plugin's instructions. Keep it short: say which
tool to use for which question, and what the numbers mean. In the body, refer to a tool as
<id>__<tool>, for example classrooms__free, because that is the name the model sees.
Tools
A tool is a piece of data with these parts:
title: the tool's name, shown to users. At most 60 characters.summary(optional): one line for people, at most 80 characters. The install review sheet and the marketplace's plugin page show it under the title. Translate it ini18n.en.tools.<name>.summary; without that, the app shows English users no summary line.description: for the model only, never shown to users.effect:read,write,moneyorpublic, declared honestly.input: the typed input fields.constantsandfunctions(optional): values and helper expressions for this tool.steps: the requests and transformations, run in order.return: an expression that builds the result.
Two flags change how the app treats a tool. untrusted: true marks a tool whose results carry
third-party text, such as posts or articles. userOnly: true means only a person's tap can run the
tool; the assistant can't.
title: 空闲教室
summary: 查某栋楼哪些教室空着
description: Free rooms in one building (or all) for today, tomorrow or the day after.
effect: read
input:
building: { type: string, optional: true, trim: true, description: 'e.g. 二教, 理教' }
day: { enum: [today, tomorrow, dayAfter], default: today }
constants:
DAY_QUERY: { today: 今天, tomorrow: 明天, dayAfter: 后天 }
steps:
- let: page
request:
url: https://portal.pku.edu.cn/publicQuery/classroomQuery/retrClassRoomFree.do
query: { buildingName: '${input.building ?? "二教"}', time: '${DAY_QUERY[input.day]}' }
headers: { referer: 'https://portal.pku.edu.cn/publicQuery/' }
as: json
- check: page.success
error: 空闲教室查询未成功
return: "page.rows.map(r => ({ room: r.room, seats: num(r.cap, 0) }))"Each input field has the form
{ type, enum?, default?, optional?, description?, min?, max?, pattern?, trim?, items? }.
type is one of string, number, integer, boolean, array and object. trim: true
removes spaces at both ends of a string before it is validated. The app checks the whole input
before any step runs, so the steps can rely on it.
Steps
Each step does one thing. A step may bind its result to a name with let: name. Later steps and
return can use every bound name, as well as input, constants, functions and now.
| Step | What it does |
|---|---|
{ let, value: expr } | Binds the value of an expression. |
{ let, request, as? } | Sends one HTTP request. |
{ let, each, concurrency?, request | steps | value } | Runs once for each item of a list. |
{ let, paginate: { request, next, items, max } } | Repeats a request page by page. |
{ let, extract: { from, each, fields }, limit? } | Picks items out of HTML. |
{ if, then, else? } | Branches. |
{ check, error } | Fails with a message when a condition is false. |
{ try, catch } | Recovers from an error; error is in scope in catch. |
{ let?, kv: get | set | delete, key, value?, ttl? } | Reads or writes the plugin's own storage. |
{ let, cache: { key, ttl, staleOnError? }, steps } | Caches the value of the inner steps in KV. |
{ let?, call: <tool>, input } | Calls another tool of the same plugin. |
{ let?, ability: <group>.<op>, args } | Calls a host ability. |
A few steps need more explanation.
A request either names a campus service with campus: <service> and a path on that service, or
gives a public https URL on one of the network hosts. as says how to read the reply: json,
text, html or response. With response you get { status, url, headers, text } for any
status code. The other forms fail when the status is not 2xx.
each runs over a list of at most 50 items, with 1 to 4 running at the same time. Inside it,
item and index are in scope. paginate repeats the request while next is not null, for at
most 30 pages.
extract selects elements from HTML with a CSS selector. Each field can pass its value through
pipes such as text, attr href, url, num, date and match <regex>.
Every run has fixed limits: 200 steps, 60 requests with at most 4 at the same time, 60 seconds,
2 MB per response, a 1 MB result, 100,000 expression operations, and 256 KB of KV storage per
plugin. The language has no unbounded loops, no eval and no JavaScript.
Expressions
Some fields hold an expression: value, each, if, check, return, next, items, from,
the input of call, the args of ability, and the bodies of functions. An expression is a single
expression in a small, safe subset of JavaScript. You can use literals, . ?. [], arithmetic,
strict == and !=, && || ?? !, a ? b : c, and arrow functions as arguments. You can't assign,
use new or reach any globals. Using a name that was never bound is an error.
Every other string is a template. In a template, ${expr} is replaced by the value of the
expression. When the whole template is exactly one ${expr}, the value keeps its type instead of
becoming a string.
The standard library covers lists (map, filter, sortBy, groupBy, sum, reduce and so
on), objects, strings, numbers, dates in Beijing time, URLs, JSON and HTML (html, select,
first, tableRows, htmlText and so on). The full list is in §6.2 of the spec, and inside the
app in plugins.reference.
A few details often catch people out:
- Regular expressions are strings. Each one is compiled once and has a length limit. For
case-insensitive and similar matching, start the pattern with inline flags, for example
'(?i)^yanyuan'. The flags arei,m,sandu. - String escapes.
\\,\',\",\n,\tand\rwork as in JavaScript. Any other escape keeps its backslash, so'\d+'is the regex\d+. This differs from JavaScript on purpose, so that patterns survive YAML unchanged. Still, write regexes in single-quoted YAML strings or in|blocks. - Comments.
// …and/* … */work inside expressions. - Merged table cells.
tableRows(node, { header: true })returns one object per row, keyed by the header labels, with rowspan and colspan expanded.tableRows(node, { cells: true })returns grid rows of{ text, row, col, rowspan, colspan, id }. Every slot covered by the same merged cell has the sameid.
Quoting expressions in YAML
Quote an expression that contains : or starts with {, [, ' or ", or write it as a
block scalar (return: |). Without quotes, YAML reads it as a map or a list. The validator points
to the line when this happens.
Results
Return plain JSON, never instructions. The app recognises a few result shapes:
{ status: 'verification_required', kind, methods, message }means the user must supply a code.{ status: 'needs_input', fields, message }means the tool needs more input from the user.{ items, hasMore, truncated }is the shape for lists.
Host abilities
A host ability is a feature of the app that a plugin may use once it lists the ability's group in
abilities. Each operation has a fixed effect, and the tool that calls it must declare at least
that effect. For example, a read tool can't call todos.add, which is a write operation. Any
name not in the list below is refused.
Plugins can't add abilities of their own. New abilities are added to the app itself, where they are reviewed once, and from then on every plugin can use them.
| Ability | Operations (effect) |
|---|---|
verify | treehole { request, code?, via?: sms | otp, kind?: login | course } (read), treeholeSendCode { kind? } (write) |
otp | code (read): the current code of the IAAA authenticator (手机令牌), or null |
todos | list (read); add, update, setDone, remove (write) |
timetable | get (read); upsert, remove (write) |
scheduler | list (read); create, update, remove (write) |
browser | open { url } (read): opens an https URL outside the app |
campusPage | open { service } (read): opens a campus site, signed in |
sms | awaitCode (read): fills in an SMS code in one tap, after the user allows reading one SMS |
files | pick { types?, multiple? } (read): lets the user pick files |
crypto | sha256 { text } (read) |
cloud | call { fn, input } (read): calls one of this plugin's server functions |
A step that calls an ability looks like this:
- let: data
ability: verify.treehole
args: "{ request: { url: '/chapi/api/course/score_v2' }, code: input.code, via: input.method ?? 'sms' }"Second factors (verify)
Treehole sometimes asks for an extra check before it answers: an SMS code at login, or a token
before it returns course data. Handling this takes several requests in the right order.
verify.treehole wraps one Treehole request and does that work, so your plugin doesn't have to.
It behaves as follows:
- If you pass a
code, it redeems the code first and then sends the request.viasays whether the code came bysmsor is a code from the authenticator app (otp). When no check is pending,kindsays which check the code is for:loginorcourse. - If the course token is needed and you passed no code, it tries a code from the bound authenticator on its own.
- Otherwise it returns
{ status: 'verification_required', kind, methods, message, next }, and the assistant asks the user for a code. - A Treehole reply without
success: truecounts as a failure, and the tool fails with Treehole's message.
verify.treeholeSendCode { kind? } sends the SMS for the check that is pending. When no check is
pending, kind picks one. If Treehole says the previous code hasn't expired yet, that also counts as
sent.
Server functions
Off on the official server for now
The server: key, { fn } bindings and the cloud.call ability exist in the SDK, but plugin
server functions are turned off on the official server for now. Keep plugins on the phone, or
use your own backend as a remote plugin.
A server: section declares functions written with the same steps and expressions. A PKU harness
Cloud server runs them, with a set of abilities of its own: server-side KV storage,
crypto.sha256, hmacSha256 and randomId, and HTTPS requests to the hosts in server.network
using secrets set by the server's operator. Server functions never get campus access.
A plugin with server: must say in cloud.disclosure what data leaves the device. The design and
its status are in
docs/CLOUD-FUNCTIONS.md,
which is on hold. See also Cloud → For plugin authors.
Install links
Students can install a plugin in four ways: paste a link in the app (设置 → 插件 → add), scan a QR
code, tap Open in app in the marketplace, or ask the assistant, which uses
plugins.install. The app accepts these link forms:
| Form | Example |
|---|---|
| GitHub folder | https://github.com/<owner>/<repo>/tree/<ref>/<path> |
| GitHub repository root | https://github.com/<owner>/<repo> (PLUGIN.md at the root) |
| GitHub file | https://github.com/<owner>/<repo>/blob/<ref>/<path>/PLUGIN.md |
Any HTTPS URL of a PLUGIN.md | a raw gist URL, your own server |
| Deep link | pkuharness://plugin?src=<url-encoded link of any form above> |
Before installing, the app fetches and validates every file. It then shows the review sheet, which tells the student three things:
- Whether the plugin is Official or Community. Official means bundled with the app or linked
from
github.com/pkuinfo/pku-harness-plugins. Everything else is community. - What the plugin can reach: campus services, which it calls with the user's signed-in session
without ever seeing the password; websites, which are its
networkhosts; and app features, which are its abilities. - What the plugin can do: every tool, with its summary and its effect.
A community plugin starts with its write, money and public tools set to ask. Its results
reach the AI as untrusted data and are never followed as instructions. Nothing is installed until the
user taps Install.
Updates
The app checks an installed plugin's link again when it starts (at most once a day) and when the user opens the plugin's page. What happens next depends on what changed:
- If the content hash is the same, nothing happens.
- If the content changed but the declarations didn't, the update is applied, with a note such as "updated to 1.3.0".
- If the declarations got wider, for example a new campus service, host or ability, or a stronger effect, the update waits until the user reviews and accepts it.
Users can turn off automatic updates or roll back to the previous version. For you as the author,
publishing an update is just a git push.
Test
bun run plugin validate path/to/my-plugin # every check the app runs, with file and step
bun run plugin test path/to/my-plugin # replay tests/*.yaml (recorded responses)
bun run plugin run path/to/my-plugin <tool> '{"k":"v"}' # live, public hosts onlyRun these commands from a checkout of pku-harness-plugins. Campus requests need the app, so record
their responses in tests/*.yaml (see the quickstart).
In the plugins repository, continuous integration (CI) replays every document's tests through
plugins/documents.test.ts.
Publish
Push the folder to a public GitHub repository, then submit the repository on the
marketplace. The marketplace finds every PLUGIN.md, checks the paths and
declarations, and shows exactly what students will see. See
Publishing and the review rules.
To make a plugin official, open a pull request that adds its folder under plugins/ in
pku-harness-plugins, together with recorded tests.
Your first plugin
Write a PLUGIN.md plugin with one tool, test it against a recorded response, and share it as a link. The example is a cut-down version of the free-classrooms plugin.
Write a plugin with the AI
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.