PKU harness
SDK reference

ui

Types for plugin UI: the props that screens and widgets receive, HostUI (components, theme, navigation, haptics, live data hooks) and every contribution type (screens, Settings rows, tabs, widgets, commands).

A plugin can add its own UI to the app, such as a screen, a Settings row or a dashboard widget. This UI is written in React, and the host provides the components, theme and data hooks it needs through HostUI. This page lists every UI-related type.

Source: packages/sdk/src/ui.ts. Guide: UI contributions.

Props

The host passes PluginScreenProps when it renders one of the plugin's screens, and PluginWidgetProps when it renders one of its widgets.

interface PluginScreenProps {
  host: HostUI;
  services: HostServices;
  /** This plugin's store. */
  kv: KV;
  /** Run one of the app's tools as the user (same validation and permissions as the agent). */
  invoke(toolId: string, input?: Record<string, unknown>): Promise<unknown>;
  params: Record<string, string | undefined>;
  /** This plugin's cloud client (plugins that declare `PluginDef.cloud`). */
  cloud?: PluginCloud;
}

interface PluginWidgetProps {
  host: HostUI;
  services: HostServices;
  kv: KV;
  invoke(toolId: string, input?: Record<string, unknown>): Promise<unknown>;
  size: WidgetSize;
  /** Agent/user-chosen options, validated by `configSchema`. */
  config: Record<string, unknown>;
  cloud?: PluginCloud;
}

HostUI

HostUI is the host in the props. It provides the host's components, haptics and navigation, and hooks that read the theme, the current time, the language and live data.

type AppLocale = 'zh-Hans' | 'en';

interface HostUI {
  components: HostComponents;
  haptics: { tap(): void; select(): void; success(): void; warning(): void };
  /** Navigate to an app route or another plugin screen (`/p/<plugin>/<screen>`). */
  navigate(href: string): void;
  back(): void;
  /** Re-render when the host's data stores change (jobs, todos, widgets, timetable). */
  useStoreVersion(): number;
  useTheme(): HostTheme;
  /** Current time, refreshed every `intervalMs`. */
  useNow(intervalMs?: number): Date;
  /** Close a modal screen (falls back to the given route when opened from a deep link). */
  dismiss(fallback?: string): void;
  /** Switch to chat and send a message to the agent. */
  ask(text: string): void;
  /** Live result of a read-only tool. `ttlMinutes` defaults to 5. */
  useToolData<T = unknown>(tool: string, args?: Record<string, unknown>, opts?: { ttlMinutes?: number; path?: string }): LiveData<T>;
  /** An AI-built widget's spec state with every binding filled in. */
  useWidgetData(widget: Pick<Widget, 'spec' | 'bindings'>): LiveData<Record<string, unknown>>;
  /** Live values for several bindings at once. */
  useBindings(bindings: Binding[]): LiveData<unknown[]>;
  /** Force-refresh every live query on screen (pull-to-refresh); resolves when all settle. */
  refreshAll(): Promise<void>;
  /** "刚刚" / "3 分钟前" / "昨天 08:00" for a past timestamp. */
  formatAgo(ms: number, now?: number): string;
  /** While mounted: block screenshots/recording; `brightness` raises it for scanners. */
  useSecureDisplay(opts?: { brightness?: boolean }): void;
  /** App language (re-renders on change). */
  useLocale(): AppLocale;
  /** Undefined when this build has no clipboard module. */
  clipboard?: { setString(text: string): Promise<void> };
  /** System share sheet with a share link; the plugin never sees the URL. Undefined until the host supports cloud channels. */
  shareLink?(link: SecretLink, opts?: { message?: string }): Promise<void>;
}

LiveData

useToolData, useWidgetData and useBindings return LiveData. It holds the current value, the loading state and the most recent error.

interface LiveData<T = unknown> {
  /** 'loading' only before the first value; afterwards errors keep the last value. */
  status: 'loading' | 'error' | 'ready';
  value?: T;
  /** Last refresh error (also set while a stale value is shown). */
  error?: string;
  /** When the shown value was fetched (ms epoch). */
  updatedAt?: number;
  /** A request is in flight. */
  fetching: boolean;
  /** Refetch now, ignoring the TTL. */
  refresh(): Promise<void>;
}

Live data is stale-while-revalidate: the cached value shows first while a fresh one is fetched in the background. The last value is kept across app launches, so it shows as soon as the screen opens.

Each value has a cache lifetime, its time to live (TTL). The host refetches when the value is older than its TTL, when the screen gets focus, when the app comes back to the foreground, once per TTL while the screen is visible, and when you call refresh(). Widgets, screens and scheduled checks that ask for the same tool with the same arguments share one request and one cache entry.

HostTheme

host.useTheme() returns the current theme. Use its colors, spacing, radii and text styles instead of hard-coded values, so that your UI follows the light and dark themes.

type TextVariant = 'display' | 'title' | 'headline' | 'body' | 'bodyStrong' | 'callout' | 'caption' | 'micro' | 'numberXL' | 'numberL' | 'numberM';

interface HostTheme {
  name: 'light' | 'dark';
  colors: Record<
    | 'bg' | 'surface' | 'surface2' | 'surface3' | 'border' | 'text' | 'textMuted' | 'textFaint'
    | 'accent' | 'accentMuted' | 'onAccent' | 'up' | 'upMuted' | 'down' | 'downMuted'
    | 'warn' | 'warnMuted' | 'info' | 'infoMuted' | 'elevated' | 'brand' | 'onBrand',
    string
  >;
  spacing: Record<'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl', number>;
  radius: Record<'sm' | 'md' | 'lg' | 'xl' | 'full', number>;
  typography: Record<TextVariant, Record<string, unknown>>;
  courseColor(index: number): { fill: string; ink: string };
  glass: { surface: string; rim: string; sheen: string };
  contentWidth: number;
}

HostComponents

The host passes these in as React components. Style is a plain React Native style and is passed through unchanged.

type Tone = 'default' | 'muted' | 'faint' | 'accent' | 'up' | 'down' | 'warn' | 'info' | 'onAccent';
type ChipTone = 'neutral' | 'accent' | 'up' | 'down' | 'warn' | 'info';

interface HostComponents {
  Screen: ComponentType<{ title: string; subtitle?: string; back?: boolean; trailing?: ReactNode; children: ReactNode; refreshControl?: ReactNode }>;
  Text: ComponentType<{ variant?: TextVariant; tone?: Tone; align?: 'left' | 'center' | 'right'; numberOfLines?: number; selectable?: boolean; style?: Style; children?: ReactNode }>;
  Button: ComponentType<{ title: string; onPress?: () => void; variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; size?: 'sm' | 'md' | 'lg'; icon?: string; loading?: boolean; disabled?: boolean; style?: Style }>;
  IconButton: ComponentType<{ icon: string; label: string; onPress?: () => void; variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; size?: number; disabled?: boolean }>;
  ListGroup: ComponentType<{ title?: string; footer?: string; children: ReactNode }>;
  ListRow: ComponentType<{ icon?: string; title: string; subtitle?: string; value?: string; accessory?: ReactNode; onPress?: () => void; chevron?: boolean; titleLines?: number }>;
  Chip: ComponentType<{ label: string; tone?: ChipTone; icon?: string }>;
  Surface: ComponentType<{ level?: 1 | 2 | 3; padded?: boolean; children?: ReactNode; style?: Style }>;
  EmptyState: ComponentType<{ icon: string; title: string; message?: string; action?: ReactNode }>;
  Skeleton: ComponentType<{ width?: number | `${number}%`; height?: number; rounded?: number }>;
  FadeIn: ComponentType<{ index?: number; children: ReactNode; style?: Style }>;
  Icon: ComponentType<{ name: string; size?: number; color?: string }>;
  Stat: ComponentType<{ label: string; value: number | null; prefix?: string; suffix?: string; decimals?: number; caption?: string; icon?: string }>;
  Switch: ComponentType<{ value: boolean; onValueChange: (v: boolean) => void; label?: string; disabled?: boolean }>;
  Checkbox: ComponentType<{ value: boolean; onValueChange: (v: boolean) => void; label: string }>;
  Segmented: ComponentType<{ options: { label: string; value: string }[]; value: string; onChange: (v: string) => void }>;
  Row: ComponentType<{ gap?: 'xs' | 'sm' | 'md' | 'lg'; align?: 'start' | 'center' | 'end' | 'between'; children: ReactNode }>;
  Column: ComponentType<{ gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; children: ReactNode }>;
  Pressable: ComponentType<{ onPress?: () => void; onLongPress?: () => void; delayLongPress?: number; accessibilityLabel?: string; accessibilityRole?: string; accessibilityHint?: string; scaleTo?: number; haptic?: boolean; disabled?: boolean; style?: Style; children?: ReactNode }>;
  Field: ComponentType<{ label?: string; value: string; onChangeText: (v: string) => void; placeholder?: string; hint?: string; multiline?: boolean; secureTextEntry?: boolean; autoFocus?: boolean; keyboardType?: string; autoCapitalize?: 'none' | 'sentences' }>;
  DateTimeField: ComponentType<{ label: string; value: Date | null; onChange: (d: Date) => void; mode?: 'date' | 'time' | 'datetime'; placeholder?: string; onClear?: () => void }>;
  Stepper: ComponentType<{ value: number; onChange: (n: number) => void; min?: number; max?: number; suffix?: string; label: string }>;
  WeekdayPicker: ComponentType<{ value: number[]; onChange: (v: number[]) => void; multiple?: boolean }>;
  SheetScreen: ComponentType<{ title: string; children: ReactNode; primary: string; onPrimary: () => void; primaryDisabled?: boolean; loading?: boolean; danger?: string; onDanger?: () => void; secondary?: string; onSecondary?: () => void }>;
  Sparkline: ComponentType<{ values: number[]; tone?: 'accent' | 'up' | 'down' | 'info'; height?: number }>;
  Ring: ComponentType<{ value: number; label?: string; caption?: string; tone?: 'accent' | 'up' | 'down' | 'warn'; size?: number }>;
  Hourglass: ComponentType<{ period?: number; size?: number; tone?: 'accent' | 'up' | 'down' | 'warn' }>;
  GenUI: ComponentType<{ spec: Record<string, unknown>; onSubmit?: (form: Record<string, unknown>, label?: string) => void }>;
  SortableList: ComponentType<{ ids: string[]; rowHeight: number; onReorder: (ids: string[]) => void; renderRow: (id: string, handle: ReactNode) => ReactNode }>;
  PluginWidget: ComponentType<{ source: { plugin: string; widget: string; config?: Record<string, unknown> }; size: WidgetSize }>;
  Glass: ComponentType<{ children?: ReactNode; style?: Style; radius?: number; tone?: 'regular' | 'strong' | 'brand'; onPress?: () => void; onLongPress?: () => void; accessibilityLabel?: string; accessibilityRole?: 'button' | 'summary' | 'none'; haptic?: boolean }>;
  GlassEdges: ComponentType<{ radius?: number }>;
  QRCode: ComponentType<{ value?: string; link?: SecretLink; size?: number; ecl?: 'L' | 'M' | 'Q' | 'H' }>;
}
ComponentNotes
ScreenRoot of a pushed screen: a header with title, back button and optional trailing element, plus pull-to-refresh.
SheetScreenForm sheet layout for modal screens: title and close button, a scrolling body, a primary action and optional secondary / danger actions.
PressableTappable container with the app's press feedback.
HourglassDrains over period seconds aligned to Unix time and flips at each boundary, for example once per refresh window of a time-based one-time password (TOTP).
GenUIRenders a generative UI spec with the host catalog.
SortableListVertical list that the user reorders by dragging a handle. The plugin renders the rows.
PluginWidgetMounts a widget contributed by a plugin. Errors stay inside the widget, and it shows a notice if that plugin is off.
Glass / GlassEdgesThe app's panel material. Use Glass for cards and tiles; GlassEdges adds the edge and top light to a container that paints its own colors.surface* fill.
QRCodeDark on white in every theme. link encodes a share link the plugin itself can't read, and wins over value when both are set.

Contributions

A plugin declares the UI it adds to the app in PluginDef.contributes, whose type is Contributions.

interface Contributions {
  /** Screens mounted at `/p/<plugin>/<screen>`. */
  screens?: Record<string, ScreenContribution>;
  /** Rows listed in Settings, opening one of the plugin's screens. */
  settings?: SettingsContribution[];
  /** Fill one of the harness's tab slots with a screen. */
  tabs?: TabContribution[];
  /** Native dashboard widgets, keyed by id. */
  widgets?: Record<string, WidgetContribution>;
  /** Slash commands, keyed by name (lowercase, /^[a-z][a-z0-9-]*$/). */
  commands?: Record<string, CommandContribution>;
}

interface ScreenContribution {
  title: string;
  component: ComponentType<PluginScreenProps>;
  /** 'modal' screens open at `/m/<plugin>/<screen>` (forms); default 'push' at `/p/…`. */
  presentation?: 'push' | 'modal';
}

interface SettingsContribution {
  /** Key of a screen in `contributes.screens`. */
  screen: string;
  title: string;
  subtitle?: string;
  icon?: string;
}

type TabSlot = 'dashboard' | 'schedule';

interface TabContribution {
  slot: TabSlot;
  /** Key of a screen in `contributes.screens`. */
  screen: string;
}

interface WidgetContribution {
  title: string;
  /** What it shows; the agent reads this to pick widgets. */
  description: string;
  sizes?: WidgetSize[];
  defaultSize?: WidgetSize;
  configSchema?: z.ZodType<Record<string, unknown>>;
  component: ComponentType<PluginWidgetProps>;
}

type CommandContribution = {
  /** One line, shown in the / menu. */
  description: string;
  /** Extra names matched by the menu, e.g. Chinese ('余额'). */
  aliases?: string[];
  /** Placeholder for the argument. Present = the command takes input. */
  argHint?: string;
} & (
  | { /** Sent as the user message; `{{input}}` is replaced. */ prompt: string }
  | {
      /** Runs locally; the returned text is shown as a notice (never sent to the model). */
      run: (input: string, ctx: { services: HostServices; navigate: (href: string) => void; locale: AppLocale }) => Promise<string | void> | string | void;
    }
);

A plugin widget can be added by the agent through dashboard.addPluginWidget, or by the user from the dashboard's widget gallery. Load the widget's data with host.useToolData, so that it refreshes the same way as every other widget. The dashboard draws the card's frame (title and background), and the widget draws only its content.

Command names are normally used as they are. A command gets a /plugin:name prefix only when two plugins pick the same name.

On this page