UI kit
Import shared components from @timon/ui so your plugin uses the same React instance, controls and theme tokens as Timon.
Timon serves these modules to the browser; nothing installs them. To type-check a browser half against them, add "types": ["@timon-ai/sdk/ui"] to your tsconfig.json (or /// <reference types="@timon-ai/sdk/ui" /> at the top of a file): @timon-ai/sdk/ui declares @timon/ui, @timon/api, @timon/theme and @timon/navigate. The components are typed loosely (the SDK does not depend on React); narrow them in your own code if you need to.
| Export | Use |
|---|---|
Slot, SlotKeyed | Render extension points your component declares |
Tile | Frame for a widget using frame: 'none' |
BrandMark | Backend brand mark using a catalogue brand or model slug; unknown brands get a fallback |
Button, Input, Label, Switch, Separator | Form primitives |
DropdownMenu*, Tooltip* | Menus and tooltips |
AnchoredPopover | Panel anchored to a button and kept inside the window — what a compact widget opens on click (see below). Requires "apiVersion": 6 |
NativeViewCover | Brings a portal you mount yourself above the desktop's built-in browser (API 14; see below) |
useOpenInNewWindow | Opens a page of the app in a new desktop window on the same machine; null outside the desktop app (API 14; see below) |
Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription | Dialog composition |
Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton | Select composition |
HUD_SURFACE | Theme-aware cockpit surface classes; opaque string for cn(HUD_SURFACE, className) |
cn | Merge class names |
t, getLocale | Product translations and current locale |
createTranslator | Translations shipped by your own plugin |
useIslandContent, openInIsland, startIslandDrag | Island tab context, opening and drag sources (API 8; see Island content) |
useUiDocument, UiDocumentFrame, uiDocumentUrl | The chat.document card: the document on screen, its sandboxed frame with bridge and focus guard, and the only URL to load (API 9; see HTML documents in a conversation) |
usePromptHistory, canRecallPrompt, isTextareaWrapped | The per-agent prompt history the chat input recalls with ↑ / ↓ (API 12; see Composer) |
useModelPicker | The model.picker occupant's host: value, onChange, inherit, filter, target, footer, disabled, mode (API 13; see Slots) |
useProviderCliCards | The providers.cli occupant's host: the AI CLI cards, their install jobs and console, and install, refresh, checkUpdates, signIn (API 14; see Slots) |
useTaskForm | The task.form occupant's host: the task (or none), the agents, submit, the attachment actions, finish, close, titleId (API 14; see Slots) |
useBackendCatalog, vendorLabel, isPickable, accountStateHint, byPower, matchesQuery, formatWindow | The model catalogue and the helpers Timon's own picker uses: provider and account label, whether an account can run and why not, models most capable first, the search (pass a trimmed, lower-cased query), « 1 M » / « 200 k » (API 13) |
useModelFavorites | The signed-in account's favourite models, shared by every picker on screen: favorites, ready, isFavorite, toggle (API 13) |
isNewModel, backendLimits, BackendLimitsBadge, AccountStateBadge | What Timon's picker shows beyond the names: the « new » badge of a model added this week, the « Remote » / « Limited » chip of a provider, the chip of an account to sign in again (API 13) |
Separator, SelectSeparator and DropdownMenuSeparator leave a blank gap and draw no line.
Plugin-owned translations avoid collisions with product keys. A missing translation key is displayed as its key rather than empty text. Ship your interface icons yourself; provider logos should use BrandMark so the app can keep them consistent.
The app's stylesheet is compiled before a third-party package is installed. Arbitrary classes such as text-[10px] or a generated conic-gradient class may not exist and silently do nothing. Use inline styles for precise values and SVG for drawings. Do not assume every standard Tailwind utility is present either; only compiled classes are available.
Theme custom properties remain usable in inline styles, for example hsl(var(--muted)) or hsl(var(--primary)). The supplied quota monitor demonstrates SVG usage rings without depending on plugin-specific stylesheet compilation.
Use the existing WidgetChromeProps type from @timon-ai/sdk for a
custom widget frame (widget: { frame: 'none' }). Forward className
to the root, render poignee in the header actions, and use editing
to show configuration controls. The SDK keeps poignee typed as unknown
to avoid a React dependency; narrow it to React.ReactNode when rendering.
DialogContent includes its portal, overlay and close button. Set
hideClose only when supplying your own accessible close control.
SelectContent includes its portal and scroll buttons. Compose it with
SelectItem children and give the trigger an accessible label.
HUD_SURFACE reuses compiled theme classes; do not depend on its literal
value or copy arbitrary Tailwind values into your plugin.
AnchoredPopover
Requires "apiVersion": 6 in the manifest: an older Timon does not export it, so a plugin declaring less would load there and fail in the browser.
A controlled panel anchored to a button: open, onClose(), anchorRef (a React ref to the button), placement ('below' on a line, 'beside' in a column — it falls back to below when neither side has room, as on a phone), width (px, default 352), maxHeight (px, default 512), label, id, className, sheet, children. It is flipped or clamped at the edges of the visible screen (above a phone's keyboard when it is open), its height is capped by the room left there and its content scrolls inside. With sheet: true, a touch screen or a screen under 640px wide gets it as a sheet across the bottom of the visible screen instead, which suits a panel with a field to type in; older Timon versions ignore the prop. className styles the panel itself: leave its position (fixed, z-*, offsets) to the component. Escape closes it and gives the focus back to the button; a press outside the panel and the button closes it. Toggle open from the button's own click.
const [open, setOpen] = React.useState(false)
const anchor = React.useRef(null)
return h(React.Fragment, null,
h('button', { ref: anchor, 'aria-expanded': open, onClick: () => setOpen((v) => !v) }, h(StatusIcon)),
h(AnchoredPopover, { open, onClose: () => setOpen(false), anchorRef: anchor, placement: 'beside', label: 'Status' },
h(StatusDetails)))
NativeViewCover
Requires "apiVersion": 14 in the manifest: an older Timon does not export it, so a plugin declaring less would load there and fail in the browser.
In the desktop app, the built-in browser is a native view painted above the whole page: no z-index reaches over it. While a NativeViewCover is mounted, the browser steps aside and shows its last capture; it comes back when the last one is gone. In a web browser it changes nothing, since the page is part of the document there.
DialogContent, SelectContent, DropdownMenuContent and AnchoredPopover already hold one, so most plugins never need it. You need it when you mount a surface yourself: your own Radix portal, a hand-written createPortal, or DialogPortal with your own content.
It takes no props and paints nothing. Put it inside the surface's content:
import { createPortal } from 'react-dom'
import { NativeViewCover } from '@timon/ui'
function Panel({ open, children }) {
if (!open) return null
return createPortal(h('div', { role: 'dialog', className: 'fixed inset-0' }, h(NativeViewCover), children), document.body)
}
Mount it only while the surface is open: left mounted, it keeps the browser frozen on its capture, including for a surface you keep mounted and merely hide. Leave it out of tooltips, toasts and a persistent overlay: they would freeze the browser for as long as they show. It is released on unmount, so unloading your plugin gives the browser back.
useOpenInNewWindow
Requires "apiVersion": 14 in the manifest.
In the desktop app, Shift+click or a middle click on a menu entry opens that page in a new window. Offer the same gesture for your own page or view with the product's hook:
import { useOpenInNewWindow } from '@timon/ui'
function Detach() {
const openWindow = useOpenInNewWindow()
if (!openWindow) return null // web browser, installed web app, older desktop app
return h(Button, { onClick: () => openWindow('/acme?tab=2') }, 'New window')
}
openWindow(route) opens route (the page on screen when left out) in one more window, on the machine the current window shows, and resolves to true once it is open. The route is a path of the app: a URL, another site, the sign-in and setup pages or /api resolve to false and open nothing. The hook returns null outside the desktop app and at the first render: hide your control then. No permission is needed; the new window shows the same interface, with the same session.
Reading usage
Both host and client contexts expose ctx.usage.list() with usage:read. Render all returned provider-defined windows rather than a fixed list of window names.
| Entry | Display |
|---|---|
available: true | Real measurements |
available: false, no error | No probe available; omit the gauge |
available: false, with error | Unavailable state; never substitute zero |
The list follows the user who made the request. Next to each provider, it holds that user's subscription accounts that are signed in, each as its own entry (backend is claude@<account id>, label is Claude · <name>). Another user's accounts are never listed, and a host call made outside a request (a job, an effect) gets no account at all. Your plugin storage is shared by every user: key what you keep by backend, and only send back what matches the entries of the current list.
The host half also has ctx.usage.get(backend), which returns one entry of that list, or null when the backend is unknown or not visible to the caller.
Probes keep their readings in a short cache, a recent failure included. When a person presses your refresh button, pass { force: true } (ctx.usage.list({ force: true }), or ctx.usage.get(backend, { force: true }) on the host) so the probes ask the provider again. Never pass it from a polling loop: Timon honours it at most once every few seconds per provider and serves the cached reading otherwise.
A host plugin can register a probe with usage:collect: ctx.usage.registerCollector(backend, collect), where collect returns the account's usage, null when it does not apply, or throws when it is temporarily unavailable. collect receives { force: true } on a refresh asked by a person: skip any cache of your own then. It is removed when the plugin unloads, cannot displace a native probe and cannot duplicate another plugin's collector for the same backend.
Backend extensions
With backends:register, prefer a profile on an existing dialect when its protocol matches. Write a dialect only for a genuinely different CLI protocol. The daemon owns process launch and checks plugin-chosen binaries against the operator policy.
Resolve credentials through secret settings or the vault and pass values to the profile. Plugins cannot use envFrom or requiredEnv to read the daemon's ambient environment. Unloading unregisters the backend and updates the catalogue. See the real provider template for both registration paths.