Vee documentation
Widgets
Vee plugins can render on your desktop and in Notification Center as native WidgetKit tiles, not just in the menu bar. Every plugin already has a widget representation for free; a plugin that opts in can print a widget card — a JSON payload describing real data — and Vee draws it with native SwiftUI.
This page covers the widget surface contract, the card schema, the five templates, and the composable layout tree for cards that the templates do not fit. For the menu-bar output format, see the Plugin authoring reference.
By default your plugin's widget tile is a scrape of its menu-bar line —
whatever color=/sfimage= is on the title, plus a progress=/sparkline=
if the first row has one. That's automatic; every plugin already has a widget
representation with no changes.
For a rich tile — real data laid out per widget size, not a caricature of the menu bar — opt a plugin into the widget surface contract:
# <vee.surface>both</vee.surface><vee.surface>menu</vee.surface>(or omit the tag) — unchanged: a normal menu-bar plugin, scraped for its widget tile.<vee.surface>both</vee.surface>— served in the menu as usual, and invoked a second time in widget mode to produce a rich widget card.<vee.surface>widget</vee.surface>— widget-only: no status item, no menu bar presence at all. The plugin exists only to feed a widget.
The widget-mode cadence needs no separate tag — it reuses the plugin's
filename interval (the same field the menu bar uses), with only a small
safety floor: max(filename interval, 10s). Because Vee is an always-running
app, it pushes widget reloads the moment new data arrives (rather than waiting
on WidgetKit's passive budget, which only applies when an app isn't running), so
a cpu.5s.sh widget can track near-real-time data straight from the menu-bar
plugin's own cadence. A widget-only plugin whose filename carries no interval
falls back to the 10-second floor.
VEE_TARGET
Every run gets a VEE_TARGET environment variable:
VEE_TARGET=menu— a normal run; print the usual xbar/SwiftBar text (or JSON).VEE_TARGET=widget— a widget-mode run; print one JSON object (the "card", schema below) to stdout and nothing else.
Branch on it like Scriptable's config.runsInWidget. If your plugin ignores
VEE_TARGET=widget and prints menu text anyway, Vee falls back to scraping
that text — graceful degradation, never a crash.
The card
{
"vee_widget": 1,
"template": "stat",
"title": "Revenue",
"symbol": "chart.line.uptrend.xyaxis",
"tint": "green",
"value": "$18.2k",
"caption": "today",
"detail": "214 orders",
"status": "ok",
"progress": 0.72,
"trend": [12.1, 13.4, 12.9, 15.0, 18.2],
"items": [
{ "label": "Orders", "value": "214", "symbol": "bag", "tint": "blue" },
{ "label": "Refunds", "value": "3", "symbol": "arrow.uturn.left", "tint": "red" }
],
"actions": [
{ "label": "Refresh", "kind": "refresh" },
{ "label": "Open", "kind": "href", "url": "https://dash.example.com" }
],
"refresh_after": 900,
"stale_after": 3600
}| Field | Type | Meaning |
|---|---|---|
vee_widget | int | Payload schema version (currently 1). |
template | enum | stat | gauge | trend | list | board. Unknown → stat + a Debug diagnostic. |
title | string? | Tile heading (the plugin/metric name). |
symbol | string? | SF Symbol name for the glyph. |
tint | color? | Named (green) or #rrggbbaa. |
value | string? | The headline value, already formatted by the plugin. |
caption | string? | Small secondary line (e.g. "today"). |
detail | string? | One more line of context. |
status | enum? | ok | warning | error — drives styling and the health roll-up. |
progress | double? | 0…1, clamped; the gauge template's fill. |
trend | [double]? | The trend template's series. |
items | [Item]? | Rows for list/board: {label, value?, symbol?, tint?}. |
actions | [Action]? | Up to two rendered as buttons — see below. |
refresh_after | int? | Seconds; a hint for the next widget reload. |
stale_after | int? | Seconds; when the tile should show a stale treatment (else the interval-derived default). |
layout | node? | A composable layout tree. When present, it replaces template entirely. |
Unknown top-level keys are ignored (forward-compatible); an invalid value
(bad progress, a non-finite trend entry, an unsafe href URL) degrades to
nil/dropped with a diagnostic, visible in the plugin's Debug console —
never a crash.
Templates
Five native SwiftUI templates, each adapting across the small/medium/large widget families — describe your data, Vee draws it:
- stat — glyph, big
valueintint,title/caption. The default. - gauge — stat + a native gauge from
progress. - trend — stat + a sparkline from
trend. - list —
titleheader +itemsas rows, truncated per family (small shows the headlinevalue; medium ≤3 rows; large ≤8). - board — a compact grid of
itemsas stat cells (a KPI board); small collapses to the headline.
If none of the five fits your data, skip template and send a
layout tree instead.
The layout tree
The five templates cover most tiles. When your data does not fit one — two columns, a header rail, a KPI grid, a ring over a label — a card can carry a layout tree instead: a small vocabulary of containers and leaves that Vee walks into native SwiftUI.
A card is either a template or a tree. Set layout and the template field
is not consulted at all.
{
"vee_widget": 1,
"layout": {
"type": "vstack",
"align": "leading",
"spacing": 6,
"children": [
{
"type": "hstack",
"spacing": 5,
"children": [
{ "type": "image", "symbol": "cpu", "style": { "tint": "blue" } },
{ "type": "text", "text": "CPU",
"style": { "font": { "size": "caption", "weight": "semibold" }, "tint": "secondary" } },
{ "type": "spacer" }
]
},
{ "type": "text", "text": "38%",
"style": { "font": { "size": "title", "design": "rounded" }, "tint": "green",
"monospaced_digit": true, "min_scale": 0.6 } },
{ "type": "gauge", "value": 0.38, "gauge_style": "circular", "style": { "tint": "green" } }
]
}
}The tree is deliberately bounded, not freeform: there is no absolute positioning, no point frames, and no scroll views. Every node maps 1:1 to a SwiftUI primitive, which is what keeps the renderer small and keeps this from turning into the WebView canvas Vee exists to avoid.
Node types
Every node has a type. Containers hold children; leaves do not.
type | Kind | Carries | Renders as |
|---|---|---|---|
vstack | container | children, align, spacing | A vertical stack |
hstack | container | children, align, spacing | A horizontal stack |
zstack | container | children, align | A depth stack — overlays, rings over labels |
grid | container | children, columns, spacing | A grid, left-aligned |
text | leaf | text | A text run |
image | leaf | symbol | An SF Symbol glyph (v1 renders SF Symbols only) |
gauge | leaf | value, gauge_style | A native gauge |
sparkline | leaf | values | A sparkline |
spacer | leaf | min_length | Flexible space |
divider | leaf | — | A hairline rule |
An unrecognised type renders nothing and produces a diagnostic, rather than
failing the card.
Node fields
| Field | Type | Applies to | Meaning |
|---|---|---|---|
type | string | all | The node kind, from the table above. Required. |
text | string? | text | The string to draw. Truncated at 512 characters. |
symbol | string? | image | SF Symbol name. |
value | double? | gauge | Fill, clamped to 0…1. |
values | [double]? | sparkline | The series. Non-finite entries are dropped; capped at 256 points. |
gauge_style | string? | gauge | linear (default) or circular. |
align | string? | containers | Cross-axis alignment — see below. |
spacing | double? | containers | Inter-child spacing in points, clamped 0…64. |
columns | int? | grid | Column count. Default 2, clamped 1…4. |
min_length | double? | spacer | Minimum space in points, clamped 0…4096. |
families | [string]? | all | Which widget sizes this node appears in — see Adapting per widget size. |
style | object? | all | Per-element styling — see below. |
children | [node]? | containers | Child nodes. |
align takes a different vocabulary per container, matching SwiftUI's own —
anything unrecognised falls back to the default:
| Container | Accepted align | Default |
|---|---|---|
vstack | leading, center, trailing | leading |
hstack | top, center, bottom | center |
zstack | topLeading, top, bottom, leading, trailing, center | center |
style
A bounded set of modifiers. Every numeric value is clamped.
| Field | Type | Meaning |
|---|---|---|
font | object? | Text font — see below. |
tint | color? | Named (green, secondary) or #rrggbbaa. |
align | string? | Multiline text alignment: leading (default), center, trailing. |
padding | double? | Uniform padding in points, clamped 0…64. |
line_limit | int? | Maximum text lines, clamped 1…20. |
monospaced_digit | bool? | Fixed-width digits — stops numeric columns jittering between refreshes. |
min_scale | double? | Minimum scale factor, clamped 0.3…1.0. Lets a headline shrink to fit instead of truncating. |
fill | bool? | Grow to fill the available width. This is the only width control; arbitrary point frames are deliberately not exposed. |
style.font:
| Field | Type | Accepted values |
|---|---|---|
size | string? | caption2, caption, footnote, subheadline, headline, title3, title2, title, largeTitle. Anything else falls back to body. |
point_size | double? | An explicit size, clamped 8…96. Wins over size when both are set. |
weight | string? | medium, semibold, bold. Anything else is regular. |
design | string? | rounded, monospaced, serif. Anything else is the default face. |
Use size by default — a semantic token scales with the system text size.
Reach for point_size only when a token cannot hit what you need, like an
oversized headline number or a very small legend.
Adapting per widget size (families)
A node with families renders only in the widget sizes it lists; a node without
it renders in all of them. Values are small, medium, and large.
This lets one tree adapt by subtraction rather than making you author three payloads — the same way the preset templates truncate their rows per size:
{ "type": "text", "text": "214 orders today", "families": ["medium", "large"] }The small tile drops that row; the medium and large tiles keep it.
Limits
The tree is sanitized before it is rendered. Every limit degrades — truncating, clamping, or dropping, with a diagnostic in the Debug console — and never fails the card:
| Limit | Value |
|---|---|
| Maximum nesting depth | 8 levels (deeper children are dropped) |
| Maximum nodes per tree | 64 (extra nodes dropped) |
Maximum text length | 512 characters (truncated) |
Maximum sparkline points | 256 (truncated) |
Unknown keys are ignored, so a tree stays forward-compatible.
Building a tree with the SDK
All three SDKs expose the same namespaced builders — Node.VStack,
Node.HStack, Node.ZStack, Grid, Text, Image, Gauge, Sparkline,
Spacer, Divider — and emit byte-identical JSON. Each of these produces
exactly the payload shown at the top of this section:
TypeScript
import { widgetCard, Node } from "./src/vee.ts";
widgetCard({
layout: Node.VStack(
[
Node.HStack([
Node.Image("cpu", { style: { tint: "blue" } }),
Node.Text("CPU", { style: { font: { size: "caption", weight: "semibold" }, tint: "secondary" } }),
Node.Spacer(),
], { spacing: 5 }),
Node.Text("38%", {
style: { font: { size: "title", design: "rounded" }, tint: "green", monospacedDigit: true, minScale: 0.6 },
}),
Node.Gauge(0.38, { gaugeStyle: "circular", style: { tint: "green" } }),
],
{ align: "leading", spacing: 6 },
),
}).print();Python
from vee import widget_card, Node
widget_card(
layout=Node.VStack(
[
Node.HStack(
[
Node.Image("cpu", style={"tint": "blue"}),
Node.Text("CPU", style={"font": {"size": "caption", "weight": "semibold"}, "tint": "secondary"}),
Node.Spacer(),
],
spacing=5,
),
Node.Text(
"38%",
style={"font": {"size": "title", "design": "rounded"}, "tint": "green",
"monospaced_digit": True, "min_scale": 0.6},
),
Node.Gauge(0.38, gauge_style="circular", style={"tint": "green"}),
],
align="leading",
spacing=6,
),
).print()Go
layout := vee.Node.VStack(
[]vee.WidgetNode{
vee.Node.HStack([]vee.WidgetNode{
vee.Node.Image("cpu", vee.Style(vee.WidgetNodeStyle{Tint: vee.Str("blue")})),
vee.Node.Text("CPU", vee.Style(vee.WidgetNodeStyle{
Font: &vee.WidgetNodeFont{Size: vee.Str("caption"), Weight: vee.Str("semibold")},
Tint: vee.Str("secondary"),
})),
vee.Node.Spacer(),
}, vee.Spacing(5)),
vee.Node.Text("38%", vee.Style(vee.WidgetNodeStyle{
Font: &vee.WidgetNodeFont{Size: vee.Str("title"), Design: vee.Str("rounded")},
Tint: vee.Str("green"),
MonospacedDigit: vee.Bool(true),
MinScale: vee.Float(0.6),
})),
vee.Node.Gauge(0.38, vee.GaugeStyle("circular"), vee.Style(vee.WidgetNodeStyle{Tint: vee.Str("green")})),
},
vee.Align("leading"), vee.Spacing(6),
)
c := &vee.WidgetCard{Layout: &layout}
c.Print()Each SDK ships this as a runnable widget-layout example with a shared golden
fixture, so the three implementations and the Swift parser cannot drift apart.
Actions
Up to two actions render as buttons:
refresh— re-runs this plugin.href— opens a URL (scheme-filtered like menuhref=:http/https/ custom app deep links; neverfile/javascript/…).shortcut— runs a named macOS Shortcut (name), like menushortcut=.
There is deliberately no shell action — a widget button must not run an
arbitrary command without the menu's context.
Building the card with the SDK
The TypeScript, Python, and Go SDKs all have Stat/Gauge/Trend/
List/Board builders that emit this JSON for you:
import { Stat } from "./src/vee.ts";
if (process.env.VEE_TARGET === "widget") {
Stat({
title: "Revenue",
symbol: "chart.line.uptrend.xyaxis",
tint: "green",
value: "$18.2k",
status: "ok",
actions: [{ kind: "refresh", label: "Refresh" }],
}).print();
} else {
// ordinary menu-bar output
}See Plugin SDKs for the Python/Go equivalents.
Editor validation (JSON Schema)
Vee publishes a schema for the card payload, so your editor can check a card while you write it — unknown fields, bad enum values, out-of-range numbers — against the same constraints Vee applies at runtime:
Reference it from a card you are authoring by hand:
{
"$schema": "https://vee.navbytes.io/schemas/widget-card.schema.json",
"vee_widget": 1,
"template": "stat",
"title": "Revenue"
}$schema is an unknown key to Vee, and unknown keys are ignored, so leaving it
in a shipped plugin is harmless.
The schema covers every field the parser reads, including the layout tree, and CI validates it against the SDKs' golden fixtures — so it cannot quietly drift from what Vee accepts.
See also
- Plugin authoring reference — the menu-bar output format.
- Plugin SDKs — build cards with typed builders in TypeScript, Python, or Go.
- JSON output format — the structured alternative for menu output.
- Getting started — adding a widget to your desktop.
- Debugging and testing plugins — previewing a plugin's output while you write it.