Skip to content

JSON output format

Alongside the xbar/SwiftBar text protocol, Vee understands an optional structured-JSON output format. A plugin prints a single JSON object describing its title and menu, and Vee decodes it directly — no line parsing, no |-separated parameters, no quoting rules.

When to use JSON vs the text protocol

JSON is the recommended format for a new plugin. It avoids the escaping and nesting rules the text format needs:

  • No quoting or escaping games. Menu text, URLs, and shell arguments that contain spaces, |, or quotes are just JSON strings. There is no | title="two words" dance and no \" escaping.
  • Typed items. Booleans are real booleans ("separator": true, "terminal": false), sizes are numbers, and there is no ambiguity between a value and a parameter name.
  • Clean nesting. Submenus are arrays nested inside an item ("submenu": [ … ]) rather than depth-prefixed with -- dashes, so deep menus stay readable and are trivial to build from a data structure.

The text protocol remains fully supported as the xbar/SwiftBar-compatibility format — it’s what lets every existing xbar/SwiftBar plugin run on Vee unchanged, and it’s still a fine choice for a quick one-liner or a plugin you want to keep portable to those tools. But if you’re starting a plugin from scratch and don’t need it to run outside Vee, reach for JSON first — especially one emitted from structured data (an API response, a config object).

The showcase plugin kitchen-sink.1m.sh is one file that exercises every field below — download and run it to see the whole format at once:

Terminal window
curl -o ~/Library/Application\ Support/Vee/plugins/kitchen-sink.1m.sh \
https://raw.githubusercontent.com/navbytes/vee/main/plugins/showcase/kitchen-sink.1m.sh
chmod +x ~/Library/Application\ Support/Vee/plugins/kitchen-sink.1m.sh

Opting in

JSON is opt-in per run. Vee uses the JSON parser only when your plugin’s output is a top-level JSON object that declares the format version:

  • The first non-whitespace character of stdout must be {.
  • The object must contain the key "vee": 1 (the current format version).

If both hold and the object decodes, Vee renders it as JSON. Otherwise it falls back to the text parser (OutputParser.parseAuto tries JSON first, then the text format). This means:

  • Malformed JSON, or JSON missing the "vee" key, silently falls through to the text parser rather than erroring.
  • Text-protocol plugins are unaffected — text rarely begins with {, and even if it does, the "vee" requirement keeps the two formats from colliding.

The minimal opt-in is a title-only object:

{"vee":1,"title":[{"text":"Hello"}]}

Schema

Top level

{ "vee": 1, "title": [ ], "items": [ ] }
KeyTypeRequiredMeaning
veenumberyesFormat version. Must be 1. Its presence is what opts the run into JSON.
titlearray of JSONTitlenoThe menu-bar title line(s). Multiple entries render as multiple title lines.
itemsarray of JSONItemnoThe dropdown body, top to bottom.

JSONTitle

An entry in title.

KeyTypeRequiredMeaning
textstringyesThe title text shown in the menu bar.
colorstringnoText color (a named color or hex, e.g. "green", "#34c759").
sfimagestringnoAn SF Symbol name to render alongside the text.
sizenumbernoFont size in points.

JSONItem

An entry in items (or in a submenu). Every field is optional; the shape of the item depends on which fields you set.

KeyTypeMeaning
textstringThe item’s label. Omitted for a separator.
separatorbooleanWhen true, this entry is a divider; all other fields are ignored.
headerbooleanWhen true, render as a real, non-interactive section header rather than a normal row (the JSON spelling of header=true).
colorstringText color (named or hex).
hrefstringA URL to open when the item is clicked.
shellstringA command to run on click (a launch path or command name).
paramsarray of stringArguments passed to shell, in order.
terminalbooleanWhen true, run the shell command in Terminal; otherwise run it in the background.
refreshbooleanWhen true, clicking re-runs the plugin.
sfimagestringAn SF Symbol name to render alongside the text.
sizenumberFont size in points.
disabledbooleanWhen true, the item is shown greyed-out and not clickable.
checkedbooleanWhen true, the item shows a checkmark.
tooltipstringHover tooltip text.
submenuarray of JSONItemChild items, forming a nested submenu.
alternateJSONItemAn alternate item shown when Option is held (mirrors the text protocol’s alternate=true).

Examples

Title-only menu

The smallest useful JSON plugin — just a menu-bar title, no dropdown.

{
"vee": 1,
"title": [{ "text": "CPU 12%", "color": "green", "sfimage": "cpu" }]
}

A dropdown with a clickable link, a divider, a nested submenu, and an Option-key alternate on the first item.

{
"vee": 1,
"title": [{ "text": "Build ✓", "color": "green" }],
"items": [
{
"text": "Open dashboard",
"href": "https://ci.example.com/builds",
"alternate": { "text": "Open dashboard (raw logs)", "href": "https://ci.example.com/builds/raw" }
},
{ "separator": true },
{
"text": "Recent",
"submenu": [
{ "text": "#4210 passed", "color": "green", "href": "https://ci.example.com/4210" },
{ "text": "#4209 failed", "color": "red", "href": "https://ci.example.com/4209" }
]
}
]
}

A shell-action item with params

An item that runs a command when clicked, passing arguments via params.

{
"vee": 1,
"title": [{ "text": "Deploy" }],
"items": [
{
"text": "Restart web server",
"shell": "/usr/bin/sudo",
"params": ["systemctl", "restart", "nginx"],
"terminal": true,
"tooltip": "Runs in Terminal so you can watch it"
},
{ "text": "Refresh", "refresh": true }
]
}

Building it with an SDK

All three SDKs have a typed builder for this format, mirroring the text-protocol Menu method for method — title, dropdown, item, separator, submenu, print — so choosing a wire format does not mean learning a second builder:

import { JSONMenu } from "./vee.ts";
const menu = new JSONMenu();
menu.title("JSON ✓", { color: "green", sfimage: "curlybraces" });
const d = menu.dropdown;
d.item("Structured item", { href: "https://example.com" });
d.separator();
d.submenu("Submenu").item("Child", { color: "blue" });
menu.print();
from vee import JSONMenu
menu = JSONMenu()
menu.title("JSON ✓", color="green", sfimage="curlybraces")
d = menu.dropdown
d.item("Structured item", href="https://example.com")
d.separator()
d.submenu("Submenu").item("Child", color="blue")
menu.print()
m := &vee.JSONMenu{}
m.Title("JSON ✓", &vee.JSONOptions{Color: vee.Str("green"), SFImage: vee.Str("curlybraces")})
d := m.Dropdown()
d.Item("Structured item", &vee.JSONOptions{Href: vee.Str("https://example.com")})
d.Separator()
d.Submenu("Submenu", nil).Item("Child", &vee.JSONOptions{Color: vee.Str("blue")})
m.Print()

The builder emits the keys in one canonical order, so the three SDKs produce byte-identical JSON for the same menu (a shared golden fixture proves it). Because the JSON format carries a subset of the text protocol’s parameters, its option type is a distinct one: an option JSON cannot express is a compile error in TypeScript and Go, and a TypeError in Python, rather than a key silently dropped on the way out.

A runnable example

The repository ships a runnable JSON plugin at plugins/typescript/examples/json-demo.ts. It builds a {"vee":1,…} object with JSONMenu — a colored title, a link, a separator, and a submenu — then prints it. A good starting point to copy.

Rich params

The Vee-native inline controls are available in JSON too, as typed item fields:

FieldTypeNotes
sparklinenumber[]Inline chart popover data. Non-finite values are dropped.
sparklineWidthnumber | "full"Sparkline width in points; "full" stretches it to the row’s width.
sparklineHeightnumberSparkline height in points.
sparklineColorstringSparkline line color (named or hex). Falls back to the item’s color.
togglebooleanOn/off switch.
slider{ "min": number, "max": number, "value": number }Requires min < max; value is clamped into range.
progressnumberA completion fraction, clamped to 0…1. The fill uses the item’s color.
progressTrackColorstringProgress track color (named or hex).
trackColorstringDeprecated — the pre-v2 spelling of progressTrackColor. Still accepted; removed in the next major version.
progressWidthnumberProgress bar width in points.
progressHeightnumberProgress bar height in points.
chart{ "kind": "pie" | "donut" | "stackedbar", "values": number[], "labels"?: string[], "colors"?: string[], "w"?: number, "h"?: number }A categorical share chart. values must be finite and >= 0 with a positive total; at most 8 segments (a longer series folds its tail into “Other”). labels/colors are positional — a color that is null, blank, malformed, or unrecognised keeps that segment’s palette slot. w/h are deprecated — use the item’s accessoryWidth/accessoryHeight, which size whichever accessory the item carries. Both are in points, clamped to 8–200; "full" stretches the chart to the row’s width.
{
"vee": 1,
"title": [{ "text": "System" }],
"items": [
{ "text": "Load history", "sparkline": [1, 2, 3, 5, 8, 13], "sparklineWidth": 120, "sparklineHeight": 18, "sparklineColor": "teal" },
{ "text": "Notifications", "toggle": true },
{ "text": "Volume", "slider": { "min": 0, "max": 100, "value": 40 } },
{ "text": "Disk usage", "color": "green", "progress": 0.72, "progressTrackColor": "#333333", "progressWidth": 80, "progressHeight": 6 },
{ "text": "By category", "chart": { "kind": "donut", "values": [45, 30, 25], "labels": ["Documents", "Photos", "Apps"] } }
]
}

These map to exactly the same controls as the text protocol’s sparkline= / toggle= / slider= / progress= / pie=,donut=,stackedbar= (see the plugin authoring reference) — the JSON form collapses the three chart shapes into one chart object with a kind, since they describe the same data — so colors, SF Symbols, links, shell actions, submenus, alternates, checkmarks, tooltips, and the rich controls are all available in both formats.

Editor validation (JSON Schema)

The JSON output format has a published schema too:

https://vee.navbytes.io/schemas/json-output.schema.json

{
"$schema": "https://vee.navbytes.io/schemas/json-output.schema.json",
"vee": 1,
"title": [{ "text": "System" }],
"items": [{ "text": "Hello" }]
}

As with the widget card, $schema is ignored by Vee, and CI validates the schema against the shipped fixtures.

See also