Vee

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:

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.

ts
#!/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();
sh
chmod +x cpu.5s.ts

Python

Create cpu.5s.py. Options are passed as keyword arguments.

python
#!/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()
sh
chmod +x cpu.5s.py

Go

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.

go
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()
}
sh
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.

The top-level menu: title line(s) plus a dropdown.

MethodTypeScriptPythonGo
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 stringtoString()to_string() / str(menu)String()
Write the rendered menu (+ newline) to stdoutprint()print()Print()

print() is what a real plugin calls.

Section

A menu section at a given submenu depth (0 = top level).

  • Itemitem(text, options?) / item(text, **options) / Item(text, *Options) adds a menu item.
  • Separatorseparator() / separator() / Separator() adds a --- divider.
  • Submenusubmenu(text, ...) / Submenu(text, ...) adds an item and returns a new Section for 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 paramsparam1..N), terminal, refresh, alternate, disabled, checked, key, tooltip, sfimage, md, badge, symbolize.

For example, in each language:

ts
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 });
python
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)
go
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

ts
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

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

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

ts
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

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

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

ts
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

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

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 .ts file with a #!/usr/bin/env node shebang.
  • 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 in fixtures/<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 VeePluginFormat tests. So if any SDK's output ever diverges from what the Swift parser expects, a test fails on one side or the other.

Commands:

sh
# 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