Vee documentation
Plugin SDKs
Vee ships tiny, zero-dependency SDKs for writing plugins with typed builders instead of hand-formatting the xbar/SwiftBar text protocol. There are three, one per language — TypeScript, Python, and Go — and they mirror each other exactly: the same builder shape, option names, encoding order, and quoting, so a plugin reads the same in any language and all three produce byte-identical output for the same menu.
The SDKs live in the plugins/ directory of the repository:
- TypeScript —
plugins/src/vee.ts - Python —
plugins/python/(README) - Go —
plugins/go/(README)
Pick whichever language you are most comfortable in; the API is the same shape in all three.
Requirements
- TypeScript — Node 24 or later (for native TypeScript execution / type stripping; there is no build step).
- Python — Python 3.9 or later (standard library only).
- Go — Go 1.21 or later (standard library only; you build the plugin to a binary).
Hello world
The same menu in each language. Adjust the import path to point at wherever the SDK lives relative to your plugin.
TypeScript
Create cpu.5s.ts. Node runs the .ts directly (type-stripping), so you drop the file straight into your plugins folder — no compile step.
#!/usr/bin/env node
import { Menu } from "./src/vee.ts";
const menu = new Menu();
menu.title("CPU 12%", { color: "green", sfimage: "cpu" });
const d = menu.dropdown;
d.item("Top processes", { href: "https://example.com/procs" });
d.separator();
const details = d.submenu("Details");
details.item("Load: 1.20");
details.item("Cores: 8");
d.item("Refresh", { refresh: true });
menu.print();chmod +x cpu.5s.tsPython
Create cpu.5s.py. Options are passed as keyword arguments.
#!/usr/bin/env python3
import sys
sys.path.insert(0, "/path/to/plugins/python")
from vee import Menu
menu = Menu()
menu.title("CPU 12%", color="green", sfimage="cpu")
d = menu.dropdown
d.item("Top processes", href="https://example.com/procs")
d.separator()
details = d.submenu("Details")
details.item("Load: 1.20")
details.item("Cores: 8")
d.item("Refresh", refresh=True)
menu.print()chmod +x cpu.5s.pyGo
Options are a *vee.Options struct; the vee.Str/vee.Int/vee.Bool helpers set the optional pointer fields concisely. Build to a binary named cpu.5s.
package main
import "vee"
func main() {
m := &vee.Menu{}
m.Title("CPU 12%", &vee.Options{Color: vee.Str("green"), SFImage: vee.Str("cpu")})
d := m.Dropdown()
d.Item("Top processes", &vee.Options{Href: vee.Str("https://example.com/procs")})
d.Separator()
details := d.Submenu("Details", nil)
details.Item("Load: 1.20", nil)
details.Item("Cores: 8", nil)
d.Item("Refresh", &vee.Options{Refresh: vee.Bool(true)})
m.Print()
}go build -o cpu.5s ./...A compiled binary is a first-class Vee plugin. The .5s in the filename sets a 5-second refresh, exactly as with any other plugin (see plugin authoring).
The API
All three SDKs expose the same three types.
Menu
The top-level menu: title line(s) plus a dropdown.
| Method | TypeScript | Python | Go |
|---|---|---|---|
| Add a title line (call more than once for multiple lines) | title(text, options?) | title(text, **options) | Title(text, *Options) |
The dropdown body (everything after ---) | dropdown (getter) | dropdown (property) | Dropdown() Section |
| Render to the text protocol string | toString() | to_string() / str(menu) | String() |
| Write the rendered menu (+ newline) to stdout | print() | print() | Print() |
print() is what a real plugin calls.
Section
A menu section at a given submenu depth (0 = top level).
- Item —
item(text, options?)/item(text, **options)/Item(text, *Options)adds a menu item. - Separator —
separator()/separator()/Separator()adds a---divider. - Submenu —
submenu(text, ...)/Submenu(text, ...)adds an item and returns a newSectionfor its children (one level deeper).
Options
Options map onto the line parameters in the authoring reference. The supported keys are the same in every SDK (in TypeScript they are ItemOptions; in Python keyword arguments; in Go the Options struct fields):
color, size, font, length, href, shell (with params → param1..N), terminal, refresh, alternate, disabled, checked, key, tooltip, sfimage, md, badge, symbolize.
For example, in each language:
d.item("Open build", { shell: "/usr/bin/open", params: ["-a", "Xcode"], terminal: false });
d.item("Inbox", { badge: "12" });
d.item("**Bold** text", { md: true });
d.item("Status :checkmark.circle:", { symbolize: true });d.item("Open build", shell="/usr/bin/open", params=["-a", "Xcode"], terminal=False)
d.item("Inbox", badge="12")
d.item("**Bold** text", md=True)
d.item("Status :checkmark.circle:", symbolize=True)d.Item("Open build", &vee.Options{Shell: vee.Str("/usr/bin/open"), Params: []string{"-a", "Xcode"}, Terminal: vee.Bool(false)})
d.Item("Inbox", &vee.Options{Badge: vee.Str("12")})
d.Item("**Bold** text", &vee.Options{MD: vee.Bool(true)})
d.Item("Status :checkmark.circle:", &vee.Options{Symbolize: vee.Bool(true)})Values containing spaces or | are quoted (and embedded quotes escaped) automatically, in every SDK — you never format the protocol by hand.
Rich params
All three SDKs expose typed builders for Vee's inline controls —
sparkline, toggle, slider, and progress — plus the progress
tuning params (trackColor, progressW, progressH). You pass structured
values; the SDK formats the protocol (numbers, ranges, and quoting) for you, so
the whole class of "I hand-formatted slider= wrong" bugs is impossible to
write. (These render natively in Vee; in xbar/SwiftBar the unknown params are
ignored, so plugins stay portable.)
TypeScript
d.item("Load history", { sparkline: [1, 2, 3, 5, 8, 13] });
d.item("Notifications", { toggle: true });
d.item("Volume", { slider: { min: 0, max: 100, value: 40 } });
d.item("Disk usage", { color: "green", progress: 0.72, trackColor: "#333333", progressW: 80, progressH: 6 });
// progress also accepts a value/max pair:
d.item("Budget", { progress: { value: 72, max: 100 } });Python
d.item("Load history", sparkline=[1, 2, 3, 5, 8, 13])
d.item("Notifications", toggle=True)
d.item("Volume", slider={"min": 0, "max": 100, "value": 40})
d.item("Disk usage", color="green", progress=0.72, trackColor="#333333", progressW=80, progressH=6)Go
d.Item("Load history", &vee.Options{Sparkline: []float64{1, 2, 3, 5, 8, 13}})
d.Item("Notifications", &vee.Options{Toggle: vee.Bool(true)})
d.Item("Volume", &vee.Options{Slider: &vee.Slider{Min: 0, Max: 100, Value: 40}})
d.Item("Disk usage", &vee.Options{Color: vee.Str("green"), Progress: vee.Float(0.72), TrackColor: vee.Str("#333333"), ProgressW: vee.Float(80), ProgressH: vee.Float(6)})Share charts
The share charts
(pie=/donut=/stackedbar=) get one typed builder rather than three: the
shapes take the same data, so kind is the only thing that changes between them.
labels and colors are optional and positional; w/h (W/H in Go) set
the inline size in points, the typed spelling of chartw=/charth=. Pass
w: "full" (Go: FullWidth: true) to stretch the chart to the row's own width.
TypeScript
d.item("By category", { chart: { kind: "pie", values: [45, 30, 25], labels: ["Documents", "Photos", "Apps"] } });
d.item("By volume", { chart: { kind: "donut", values: [512, 256, 128], colors: ["blue", "teal", "orange"] } });Python
d.item("By category", chart={"kind": "pie", "values": [45, 30, 25], "labels": ["Documents", "Photos", "Apps"]})
d.item("By volume", chart={"kind": "donut", "values": [512, 256, 128], "colors": ["blue", "teal", "orange"]})Go
d.Item("By category", &vee.Options{Chart: &vee.Chart{
Kind: "pie", Values: []float64{45, 30, 25}, Labels: []string{"Documents", "Photos", "Apps"},
}})
d.Item("By volume", &vee.Options{Chart: &vee.Chart{
Kind: "donut", Values: []float64{512, 256, 128}, Colors: []string{"blue", "teal", "orange"},
}})Vee reads labels and colors as comma-separated lists, so a segment name can't contain a comma; names with spaces are quoted for you.
All three emit byte-identical protocol output (there are controls and charts
examples with shared golden fixtures proving it). See the underlying
line-parameter grammar in the
plugin authoring reference.
Widget cards
All three SDKs also build the rich widget card
payload a plugin prints when invoked with VEE_TARGET=widget — a generic
widgetCard(...)/WidgetCard(...) constructor, plus Stat/Gauge/Trend/
List/Board convenience builders that preset the template field. Each
returns/builds an object with the same toString()/to_string()/String()
+ print() shape as Menu.
TypeScript
import { Stat } from "./src/vee.ts";
Stat({
title: "Revenue",
symbol: "chart.line.uptrend.xyaxis",
tint: "green",
value: "$18.2k",
status: "ok",
items: [{ label: "Orders", value: "214", symbol: "bag", tint: "blue" }],
actions: [{ kind: "refresh", label: "Refresh" }],
}).print();Python
from vee import Stat
Stat(
title="Revenue",
symbol="chart.line.uptrend.xyaxis",
tint="green",
value="$18.2k",
status="ok",
items=[{"label": "Orders", "value": "214", "symbol": "bag", "tint": "blue"}],
actions=[{"kind": "refresh", "label": "Refresh"}],
).print()Go
c := &vee.WidgetCard{
Template: vee.TemplateStat,
Title: vee.Str("Revenue"),
Symbol: vee.Str("chart.line.uptrend.xyaxis"),
Tint: vee.Str("green"),
Value: vee.Str("$18.2k"),
Status: vee.StatusOK,
Items: []vee.WidgetCardItem{{Label: "Orders", Value: vee.Str("214"), Symbol: vee.Str("bag"), Tint: vee.Str("blue")}},
Actions: []vee.WidgetCardAction{{Kind: vee.ActionRefresh, Label: "Refresh"}},
}
c.Print()All three emit byte-identical JSON for the same card (there's a widget-card
example and a shared golden fixture proving it, also round-tripped through
the Swift parser). See the full field/template/action reference in
Widgets.
The no-build-step note (TypeScript)
For TypeScript there is deliberately no compiler or bundler in the loop. Node 24+ strips the TypeScript types at load time and runs the file, so:
- Your plugin is a plain
.tsfile with a#!/usr/bin/env nodeshebang. - You edit it and Vee re-runs it — nothing to compile.
- The SDK ships as source (
src/vee.ts), imported directly.
Python plugins run the same way (no build). Go plugins are compiled once to a binary, which Vee then runs like any other executable plugin.
Drift guard and fixtures
The SDKs, the golden fixtures, and the Swift parser are kept in lockstep by a fixture drift guard:
- Each example (
examples/*.ts,python/examples/*.py,go/examples/*) builds a menu and its committed output lives infixtures/<name>.txt. - Each SDK's test asserts that its examples still match those fixtures.
- The same fixtures are shared byte-for-byte across all three SDKs and are parsed by the Swift
VeePluginFormattests. So if any SDK's output ever diverges from what the Swift parser expects, a test fails on one side or the other.
Commands:
# TypeScript (run from plugins/)
npm test # run the drift guard (node --test)
npm run build:fixtures # regenerate fixtures from the examples
# Python (run from plugins/python)
python3 -m unittest discover -s test -v
# Go (run from plugins/go)
go test ./...If you change an SDK's output, regenerate the fixtures and run the tests — and the Swift-side tests will confirm the parser still agrees.
See also
- Writing plugins with an LLM — the SDKs make several of the format's easiest mistakes impossible.
- Plugin authoring reference — the underlying text format the SDKs emit, including the rich params.
- JSON output format — the optional structured-JSON alternative to the text protocol.
- Getting started — where the plugins folder is.
- Python SDK README —
plugins/python/README.md. - Go SDK README —
plugins/go/README.md.