Dashboard widgets and data
There are two kinds of dashboard widget. The agent builds some from a generative-UI spec plus bindings to read tools, and plugins provide others as native components. The host handles refreshing, with one cache shared by all of them.
The dashboard plugin provides the Dashboard tab. The tab is an ordered list of widgets, such as
your campus-card balance, your next class and the next shuttle bus.
You can tell the agent in a conversation which numbers you want to see, for example "show my card balance and the next shuttle", and it builds or edits the dashboard. You can also long-press a widget to move it, resize it or delete it, and you can add widgets that plugins provide from the widget gallery.
The two kinds of widget
There are two kinds of widget on the dashboard, and they can be mixed freely.
An AI-built widget is described by { spec, bindings }. The agent decides how it looks by
writing a generative-UI spec with dashboard.upsertWidget.
Its data comes from bindings: each binding maps a path in the state to a read tool.
A plugin-provided widget is described by { source: { plugin, widget, config? } }. It is the
plugin's own native component, declared in contributes.widgets. The dashboard wraps it in an
error boundary, so one failing widget doesn't break the others. The component gets its data by
calling host.useToolData(...).
When the agent builds a dashboard, it looks at dashboard.catalog first. If a plugin widget
fits, it places that widget (dashboard.addPluginWidget). It only builds its own widget when no
plugin widget fits.
For both kinds, the dashboard draws the frame around the widget. The frame contains the title, how fresh the data is, and the long-press menu.
Widget model
type Widget = {
id: string;
title: string;
size: 'sm' | 'md' | 'lg'; // sm = half-width tile, md = full width, lg = full-width hero
spec: Record<string, unknown>; // json-render spec (empty for plugin widgets)
bindings: Record<string, Binding>; // JSON pointer in state → data source
source?: { plugin: string; widget: string; config?: Record<string, unknown> };
position: number;
createdAt: number;
updatedAt: number;
};
type Binding = {
tool: string; // read-only tool id, e.g. "calendar.today"
args?: Record<string, unknown>;
path?: string; // dot path into the result; empty = whole result
ttlMinutes?: number; // 0 – 1440, default 5
};There are three sizes. sm is a half-width tile, md fills the width, and lg is a full-width
large card.
Here is an AI-built widget that shows today's classes. Its bindings map /today in the state to
the timetable.today tool, cached for 1 minute.
{
"title": "下一节课",
"size": "md",
"spec": {
"root": "col",
"elements": {
"col": { "type": "Column", "props": { "gap": "sm" }, "children": ["h", "t"] },
"h": { "type": "Heading", "props": { "text": { "$template": "今天共 ${/today/count} 节课" }, "size": "md" } },
"t": { "type": "Text", "props": { "text": { "$template": "下一节:${/today/next/name} ${/today/next/time}" }, "tone": "muted", "size": null } }
}
},
"bindings": { "/today": { "tool": "timetable.today", "ttlMinutes": 1 } }
}How widgets are refreshed
The host takes care of refreshing widgets, so plugins don't have to.
Every read-tool result is kept in one store, keyed by tool and arguments. AI-built widgets, plugin widgets, plugin screens and scheduled checks all share this store. If you know TanStack Query, this is the same model, called stale-while-revalidate: the cached value is shown first while a fresh one is fetched in the background. The rules are as follows.
- The last value is kept. After a cold start, the numbers appear immediately, labelled with their age, for example "3 分钟前" (3 minutes ago). You only see a loading skeleton when nothing has ever been fetched.
- A value goes stale once its time to live (TTL) has passed. The default TTL is 5 minutes. A stale query is fetched again only on the focused screen: when the screen gains focus, when the app comes back to the foreground, and on a timer while the screen is visible. Hidden tabs and the app in the background never poll.
- Pull-to-refresh forces every query on the screen to refresh. Identical requests made at the same time share one promise, so they are sent only once.
- If a refresh fails, the last value stays and is marked, for example "刷新失败 · 10 分钟前" (refresh failed, 10 minutes ago).
- Scheduled checks write into the same store. A balance check in the background therefore also updates the balance widget on the dashboard.
For this reason, a plugin widget must not poll on its own timer. Load data with
host.useToolData, and choose a TTL that matches how fast the data changes. Balances suit 5–15
minutes, and data that changes slowly suits 60 minutes or more.
Writing a plugin widget
This plugin widget shows how crowded the canteens are. It reads the canteen.crowd tool with
host.useToolData and a TTL of 2 minutes. At the small size it lists 3 canteens, otherwise 6.
import type { PluginWidgetProps } from '@pku-harness/sdk';
import { View } from 'react-native';
type Crowd = { name: string; percent: number }[];
export function CrowdWidget({ host, size }: PluginWidgetProps) {
const { Text, Skeleton } = host.components;
const t = host.useTheme();
const data = host.useToolData<{ canteens: Crowd }>('canteen.crowd', {}, { ttlMinutes: 2 });
if (data.status === 'loading') return <Skeleton width="60%" height={20} />;
if (!data.value) return <Text tone="muted">{data.error ?? '暂无数据'}</Text>;
const list = data.value.canteens.slice(0, size === 'sm' ? 3 : 6);
return (
<View style={{ gap: t.spacing.xs }}>
{list.map((c) => (
<Text key={c.name} variant="callout">{`${c.name} ${c.percent}%`}</Text>
))}
</View>
);
}Then register the component under contributes.widgets and give it a description written for
the agent. The agent reads this description when it chooses widgets. See UI contributions.
Generative UI
The agent describes a piece of UI in JSON, and the app shows it with native components, either in the chat (`ui.render`) or on the dashboard. A submitted form comes back to the agent as your next message.
Scheduler and notifications
One scheduler runs reminders, to-dos, class reminders and scheduled checks. Each job pairs a trigger (once, or a series with an end) with an action (notify, check or agent), and fires on the minute.