PKU harness
Build a plugin

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:

  • classrooms sends public requests in parallel with each, defines aliases in constants, and has a dashboard widget.
  • campus-events reads Treehole (树洞) calendars through verify.treehole.
  • grades handles a second factor, by SMS or by the authenticator app (手机令牌) of IAAA, PKU's central sign-in system, with verify.treehole and verify.treeholeSendCode.
  • canteen uses a shared: file, caches results in key-value (KV) storage with a fallback to stale data, and reads HTML tables with merged cells.
  • shuttle uses no network at all. Its timetables are written into the plugin as data, and its widget refreshes every minute.
  • notices reaches a wildcard host (*.pku.edu.cn) and WeChat articles. It keeps a table of sources in shared:, caches each page in KV with a stale fallback, marks tools that return third-party text with untrusted: 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:

classrooms/PLUGIN.md
---
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

  • shared names a YAML file of constants and functions that every tool can use. When a tool defines the same name itself, the tool's own definition wins.
  • dataSources, widgets and screens add live data and generative-UI views.
  • settings and commands add Settings rows and slash commands.
  • defaultModes sets the starting permission mode for each effect.
  • i18n holds the English text. It can translate the name, the description, the instructions, and each tool's title and summary (i18n.en.tools.<name>).
  • remote: { mcp, auth } describes a remote MCP server with a PLUGIN.md.
  • cloud declares the plugin's use of PKU harness Cloud, including cloud.disclosure, and server declares 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 in i18n.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, money or public, declared honestly.
  • input: the typed input fields.
  • constants and functions (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.

tools/free.yaml (abridged)
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.

StepWhat 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 are i, m, s and u.
  • String escapes. \\, \', \", \n, \t and \r work 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 same id.

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.

AbilityOperations (effect)
verifytreehole { request, code?, via?: sms | otp, kind?: login | course } (read), treeholeSendCode { kind? } (write)
otpcode (read): the current code of the IAAA authenticator (手机令牌), or null
todoslist (read); add, update, setDone, remove (write)
timetableget (read); upsert, remove (write)
schedulerlist (read); create, update, remove (write)
browseropen { url } (read): opens an https URL outside the app
campusPageopen { service } (read): opens a campus site, signed in
smsawaitCode (read): fills in an SMS code in one tap, after the user allows reading one SMS
filespick { types?, multiple? } (read): lets the user pick files
cryptosha256 { text } (read)
cloudcall { fn, input } (read): calls one of this plugin's server functions

A step that calls an ability looks like this:

A tool step with an ability
- 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. via says whether the code came by sms or is a code from the authenticator app (otp). When no check is pending, kind says which check the code is for: login or course.
  • 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: true counts 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.

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:

FormExample
GitHub folderhttps://github.com/<owner>/<repo>/tree/<ref>/<path>
GitHub repository roothttps://github.com/<owner>/<repo> (PLUGIN.md at the root)
GitHub filehttps://github.com/<owner>/<repo>/blob/<ref>/<path>/PLUGIN.md
Any HTTPS URL of a PLUGIN.mda raw gist URL, your own server
Deep linkpkuharness://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 network hosts; 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 only

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

On this page