Vee documentation
Plugin authoring reference
A Vee plugin is any executable that prints text to standard output in the xbar/SwiftBar format. This page is the full reference: filenames, menu structure, the parameter table, metadata headers, and the richer features (SF Symbols, ANSI, Markdown, streaming, cron).
If you would rather build menus with typed code than format text by hand, see the Plugin SDKs (TypeScript, Python, and Go). For a structured alternative to the text protocol, see the JSON output format.
The authoring loop
Before the reference, the workflow it is meant to be read alongside. Vee gives you a save-driven loop that needs no app running and no plugin installed:
vee dev ./cpu.10s.shKeep that in a split terminal beside your editor. Every save re-runs the file and repaints the menu tree Vee would build, together with any lint findings. A save that breaks the script shows the exit code and stderr and keeps watching, so you never have to restart the loop over a typo.
Two flags make it a design tool rather than only a debugger:
vee dev --text menu.txttreats the file as plugin output and never executes it. Sketch the shape of a menu — sections, submenus, colors, charts — as plain text, watch it render, and only then write the script that produces it. Nothing runs, so the file needs no shebang and no execute bit.vee dev --push ./cpu.10s.shadditionally shows each save as a real status item in the menu bar, with no file written to your plugins folder. The terminal shows you the structure; the menu bar shows you Vee's actual render.
For inline diagnostics in your editor, vee lint --format compact emits
path:line:col: severity: message, which VS Code, vim, and emacs already parse —
no extension required. See Debugging and testing plugins for the
loop's full flag list, a copy-pasteable VS Code problemMatcher, and a note on
which file a finding is attributed to.
Editing a plugin that is already installed works too: Vee watches each plugin file and re-reads it shortly after you save, so the menu bar keeps up without a relaunch.
Filenames and refresh intervals
The refresh interval is encoded in the filename as name.INTERVAL.ext:
cpu.5s.sh → run every 5 seconds
mail.10m.py → run every 10 minutes
backup.1h.rb → run every hour
report.1d.js → run every day
ping.500ms.sh → run every 500 milliseconds
weather.sh → no interval → run once / on demand onlyInterval units: ms (milliseconds), s (seconds), m (minutes), h (hours), d (days). The interval token is only recognised when it sits immediately before the extension and there is a name in front of it, so 10s.sh is treated as a plugin named 10s with no interval, not an anonymous 10-second plugin.
A plugin without an interval token runs on demand (and on launch). You can also drive scheduling with a cron header — see Cron schedules.
Make every plugin executable:
chmod +x cpu.5s.shIf a file is not marked executable, Vee still tries to run it using its shebang interpreter, falling back to /bin/bash. Marking it +x is the reliable path.
Title vs. dropdown
Standard output is split into two parts by the first --- line:
CPU 12% ← menu-bar title (before ---)
--- ← separator
Top processes ← dropdown items (after ---)
Details- Everything before the first
---is the menu-bar title. You can print multiple title lines; Vee cycles or stacks them. - Everything after
---is the dropdown menu. - A plugin with no
---shows only a title and no dropdown.
Separators and submenus
---on its own line inside the dropdown draws a divider.- Prefix a line with
--to nest it one level into a submenu. The item immediately above becomes the submenu's parent. Each extra--nests one more level.
---
Network
-- Wi-Fi: connected
-- IP: 192.168.1.20
-- Speed
---- Down: 120 Mbps
---- Up: 24 MbpsHere Network is a submenu containing Wi-Fi, IP, and a further Speed submenu.
Section headers
Mark a line header=true to render it as a real, non-interactive section header — AppKit's native section-header row — instead of a disabled=true line dressed up to look like one:
---
Accounts | header=true
Checking
Savings
---
Cards | header=true
Visa ···· 4242A header row is title-only: it ignores click/appearance params (href=, color=, md=, …) since AppKit's native section header renders plain text and never fires an action. Keep it at the same indentation as the items it introduces — like disabled=true today, it doesn't nest anything under itself.
In the search panel, section headers render as dimmed context rows and become part of each row's breadcrumb (e.g. Accounts › Checking); they are searchable, so typing a section name surfaces its rows. Header submenus — a header=true line with child items indented under it — never surface in the search results.
Line parameters
Append | key=value key2=value2 … to any line to attach parameters. Quote values that contain spaces (title="Open in browser"), and escape quotes with \".
A literal |, backslash, or newline in the display text (or in a quoted value) must be escaped as \|, \\, or \n — otherwise an unescaped | is read as the params delimiter and truncates the item, and a raw newline splits it into two corrupted lines. The bundled TypeScript, Python, and Go SDKs escape these automatically for any text/value you pass in; only hand-written plugin output needs to do it explicitly.
| Parameter | Description |
|---|---|
color | Text color — a named color (red, green, …) or a hex value like #00ff00. |
font | Font family name for the text. |
size | Font point size. |
length | Truncate the displayed text to this many characters. |
trim | true/false — trim surrounding whitespace from the text. |
href | Open this URL when the item is clicked. |
shell / bash | Run this command on click. Positional args come from param0, param1, … |
param0, param1, … | Ordered arguments passed to shell/bash. |
terminal | true to run the shell command in a visible Terminal window; false to run it in the background. |
refresh | true — re-run the plugin when the item is clicked. |
dropdown | false — show the line only in the menu bar, not the dropdown. |
alternate | true — this line is the Option-key alternate of the line above it. |
disabled | true — render the item greyed-out and non-clickable. |
header | true — render this line as a real, non-interactive section header instead of a normal item. |
key | Keyboard shortcut for the item, active while the menu is open (e.g. key=Cmd+R, key=shift+F2, key=cmd+space). |
image | Base64-encoded image (or file reference) shown next to the text. |
templateImage | Like image, but treated as a template image that adapts to light/dark. |
sfimage | SF Symbol name to show as the item's icon (e.g. sfimage=cpu). |
sfcolor | Color(s) for the SF Symbol; comma-separated for multicolor symbols. |
sfsize | Point size for the SF Symbol. |
sfconfig | SF Symbol configuration as JSON — scale (small/medium/large) and weight (e.g. bold). Example: sfconfig='{"scale":"large","weight":"bold"}'. |
symbolize | true — render inline :symbol.name: tokens in the text as SF Symbols. |
md / markdown | true — render the text as inline Markdown (bold, italics, etc.). |
ansi | true/false — interpret ANSI color escape codes in the text. On by default; set ansi=false to disable. |
emojize | true/false — convert :shortcode: tokens (e.g. :smile:) into emoji. |
tooltip | Hover tooltip text. |
checked | true — show a checkmark next to the item. |
badge | A short badge/chip shown after the text (e.g. badge=12). |
shortcut | Run a macOS Shortcut by name when the item is clicked (e.g. shortcut="Start Meeting"). |
webview, webvieww, webviewh | Open a URL in a standalone WebView window (never inside the menu), with optional width/height. |
sparkline | A comma-separated list of numbers (e.g. sparkline=1,2,3,4,5). Renders as a small chart inline in the menu row; clicking the item also opens a fuller native Liquid Glass Swift Charts popover. |
toggle | toggle=on / toggle=off (also true/false/1/0). Clicking opens a Liquid Glass popover with a switch; flipping it re-invokes the item's shell=/bash= with the new value. |
slider | slider=min,max,value (e.g. slider=0,100,40). Clicking opens a Liquid Glass popover with a slider; releasing it re-invokes the item's shell=/bash= with the chosen value. |
progress, trackcolor, progressw, progressh | progress=<0..1> or progress=value,max (e.g. progress=0.72 or progress=23.65,100). Draws a real capsule bar inline in the menu row. Fill uses color=; trackcolor= is the groove, progressw=/progressh= set the bar size in points. |
pie, donut, stackedbar | A comma-separated list of non-negative numbers read as shares of a whole (e.g. pie=45,30,25). Draws the chart inline in the menu row; clicking the item opens a fuller Swift Charts popover with a labelled legend. See Share charts. |
chartw, charth | The chart's inline size in points (defaults: a 24pt circle, a 110×12 bar; clamped to 8–200). A pie or donut is a circle, so either one sizes both sides. chartw=full stretches the chart to the row's own width instead. |
chartlabels | Segment names for a chart, positional against its values (e.g. chartlabels=Docs,Photos,Apps). Shown in the popover legend and read out by VoiceOver. |
chartcolors | Segment colors for a chart, positional against its values (e.g. chartcolors=blue,,orange). An entry that is blank, malformed, or names a color Vee doesn't know keeps that segment's default palette color. |
accessory | leading / trailing — which edge of the row a progress=/sparkline=/chart accessory anchors to (default trailing, today's rendering). See Accessory placement. |
Unknown parameters are preserved rather than dropped, so the format can evolve without breaking existing plugins.
Rich inline charts (Liquid Glass popovers)
Attach sparkline= to a dropdown item to render a compact chart inline in the
menu row — the same in-row custom view progress= uses:
Load average | sparkline=0.4,0.6,0.9,1.2,0.8,0.5Clicking the item still opens the richer surface: an NSPopover that renders the
same numbers as a full Swift Charts line/area sparkline on a macOS 26 Liquid Glass
background. This is Vee's answer to "rich plugin UI without a WebView" —
everything is drawn with SwiftUI + Swift Charts and AppKit, so there is no
embedded browser or cross-platform runtime. Malformed values are skipped; an
empty list is ignored (no inline chart, no popover). A single value has no series
to chart, so it draws as a flat centered baseline instead.
Interactive controls (toggle= / slider=)
Attach toggle= or slider= to a dropdown item to open an interactive
Liquid Glass popover — a live switch or slider, again drawn natively with SwiftUI
and AppKit (no WebView, no embedded runtime):
Wi-Fi | toggle=on shell=/usr/local/bin/wifi.sh
Volume | slider=0,100,40 shell=/usr/local/bin/volume.shWhen you change the control, Vee re-invokes the item's shell=/bash= command
with the new value provided two ways, so you can read whichever is convenient:
- the
VEE_CONTROL_VALUEenvironment variable, and - the value appended as the command's final argument.
Toggles pass 1/0; sliders pass the numeric value (integers without a trailing
.0). Add refresh=true to re-render the menu bar after the command runs.
#!/bin/bash
# volume.sh — called with the new slider value
osascript -e "set volume output volume $VEE_CONTROL_VALUE"A slider needs three numbers (min,max,value) with min < max; the value is
clamped into range. Malformed controls are ignored.
Proposal, subject to change. The
sparkline=,toggle=, andslider=syntax (and the popover surface they opt into) are an early proposal; the exact convention may still evolve.
Inline progress bars (progress=)
Unlike the popover items above, progress= draws a real capsule bar right in
the menu row — no click, no popover. It's the native answer to hand-drawn
block-glyph bars:
$23.65 of $100 | progress=23.65,100 color=#36C26E trackcolor=#3C4046 progressw=210
Disk | progress=0.88 color=#F5A623progress=<0..1>(a fraction) orprogress=value,max(mirrorsslider='s grammar). The result is always clamped to0…1.- The fill color is the row's
color=;trackcolor=sets the groove. progressw=/progressh=set the bar's width/height in points (defaults 120×6).- The row's text renders to the left of the bar; the row auto-sizes so the label never truncates. Unknown to xbar/SwiftBar, so plugins stay portable (they just ignore it).
The gauge itself is display-only (it doesn't fire a click by being a gauge), but
the row can still carry its own href=/shell= action or a submenu, exactly
like a plain item.
If a row sets more than one inline accessory, the first of
progress= → sparkline= → chart takes the in-row view; the click-to-popover
that sparkline= and the charts opt into still opens as normal either way.
Share charts (pie=, donut=, stackedbar=)
Where sparkline= shows a value over time, these show how a total divides
up. All three take the same data — one series of non-negative numbers read as
shares of a whole — so switching shapes means changing one word:
By category | pie=45,30,25 chartlabels=Documents,Photos,Apps
By volume | donut=512,256,128 chartlabels="Macintosh HD,Backup,Scratch"
Budget | stackedbar=60,25,15 chartlabels=Used,Cache,FreeThe chart draws inline in the menu row (a small pie, donut, or capsule bar,
using the same accessory slot progress= and sparkline= use). Clicking the row
opens a Liquid Glass Swift Charts popover with the chart at full size and a
legend naming every segment with its percentage — the popover is where
chartlabels= becomes visible, since a menu row has no space for them. A donut
also shows the series total in its hole.
Size. An inline chart is small by default — it shares a menu row with its
label. chartw=/charth= make it as large as the row can carry (clamped to
8–200 points); the row grows to fit. A pie or donut is a circle, so either knob
sizes both sides:
| pie=45,30,25 charth=56 chartlabels=Documents,Photos,Apps
Budget | stackedbar=60,25,15 chartw=200 charth=16Leaving the text before | empty, as in the first line, gives the chart the
whole row — useful when a legend of ordinary rows underneath already names the
segments.
chartw=full stretches a chart to the width the row actually has, rather than a
number of points:
| stackedbar=60,25,15 chartw=full charth=14
By model | stackedbar=60,25,15 chartw=fullA menu is as wide as its widest row, so a fixed chartw= can't fill a menu whose
width some other row decides — full can. A row with text keeps it, and the
chart takes the width that remains; a row with none gives the chart everything
between the menu's insets. A full-width chart never widens the menu itself, and
in a menu too narrow to stretch into it falls back to its normal size.
Colors. Segments take Vee's built-in categorical palette by position, so
segment 1 is always the same hue no matter how many segments a plugin emits.
The palette is eight fixed slots, selected separately for light and dark mode and
checked for color-blind separation — so you don't have to pick colors at all.
Override them positionally with chartcolors= when your data has its own
conventional colors:
Storage | donut=512,256,128 chartcolors=blue,teal,orange
Status | stackedbar=8,1,3 chartcolors=,,redLeave an entry blank (as in chartcolors=,,red above) to recolor just one
segment and keep the palette for the rest. Unlike sfcolor=, positions are never
compacted: a blank or malformed entry stays a hole rather than sliding the next
color onto the wrong segment. A name Vee doesn't recognise also falls back to the
palette, so a typo costs you one segment's color, never the alignment of the
rest.
Rules the parser enforces, so a chart never misrepresents its data:
- Every value must be a finite number and
>= 0, and at least one must be positive. A negative, non-numeric, or all-zero series is ignored (with a diagnostic invee lint) rather than drawn wrong. - At most 8 segments. A longer series isn't truncated — that would silently rescale every slice that survived — the first 7 are kept and the rest are summed into a neutral "Other" segment, so the shares still add up to your own total.
chartlabels=/chartcolors=are comma-separated, so a segment name can't itself contain a comma. Names with spaces are fine — quote the whole list, as inchartlabels="Macintosh HD,Backup,Scratch".
vee show renders all three kinds as one segmented block bar (a terminal can't
draw sectors); vee render names the shape.
Proposal, subject to change. Like
sparkline=/toggle=/slider=, the chart syntax is an early proposal; the exact convention may still evolve.
Accessory placement (accessory=)
progress=, sparkline=, and the share charts anchor their accessory
(bar/chart) to the row's trailing edge by default, with the label filling the
rest — today's rendering. Set accessory=leading to flip it: the accessory
anchors to the row's leading edge instead, with the label filling the remaining
trailing space.
Budget | progress=0.72 accessory=leadingOmit accessory= (or set accessory=trailing) for today's default.
Searchable filter panel
Big menus — dozens of items across nested submenus — are slow to scan. Opt a plugin into a searchable filter panel and its dropdown gains a Search… row (⌘F) that opens a Spotlight-like popover: type to filter every item at once (including those nested inside submenus), flattened into a ranked list, each with a breadcrumb of its parent groups.
# <vee.filter>true</vee.filter>Before typing (idle state): the panel mirrors your dropdown's structure — section
headers (header=true), separators (---), and non-actionable rows (disabled=true,
plain sub-text) all appear dimmed and non-selectable, just like the native menu. This
lets users browse and orient themselves without typing. Keyboard selection skips dimmed
rows; only actionable rows can be activated with Return or click.
While typing: results flatten into a ranked list. Dimmed info rows match the search haystack and display, but stay non-activatable — you search to find what you're after, or just navigate structure when exploring.
Section titles as context: headers join the breadcrumb of rows under them (e.g.
Accounts › Checking), so typing a section name surfaces all its rows. Nested submenus'
headers and separators don't render as rows themselves — their structure is carried by
breadcrumbs like Tools › Nested › Item. Header submenus (a header=true line with
children indented under it) never surface in search.
- Fuzzy matching —
ghfindsGitHub; multiple words are ANDed together. - Keyboard-driven — ↑/↓ move the highlight, Return activates, Esc closes.
- Activating a row runs its normal action —
href,shell/bash,shortcut,refresh, and thetoggle/slider/sparklinepopovers all work exactly as they do from the menu.
The panel is an addition, not a replacement: the native menu, its trust row, and Vee's own controls all stay exactly where they are.
Global hotkey (<vee.shortcut>)
Bind a system-wide hotkey that opens the panel from anywhere — no need to open the menu first, and Vee doesn't have to be the active app:
# <vee.shortcut>cmd+shift+k</vee.shortcut>Modifiers are cmd/command/⌘, shift/⇧, opt/option/alt/⌥, and
ctrl/control/⌃; the key can be a letter, a digit, F1–F12, space,
return, tab, escape, or an arrow. Order doesn't matter and it's
case-insensitive (⌘⇧K works too), but at least one modifier is required. Vee
registers it with the system hotkey API, so no Accessibility permission is
needed; if the combination is already taken system-wide, Vee logs it and moves
on. The user stays in control: a plugin's hotkey can be turned off or rebound
from the plugin's Settings, where its live status (active / in-use / invalid) is
shown.
The plugin's Settings also chooses what the hotkey opens — the search panel (the default) or the plugin's window. Nothing is given up either way: the window carries the same search field. With Window selected, pressing the hotkey when the window is already open brings it back to the front, which is the quickest way back to a window you have unpinned and covered up.
Both tags are strictly opt-in — omit them and the plugin behaves exactly as
before. Whatever a plugin declares, Vee surfaces under its Features — in the
menu's capabilities area and the plugin's Settings window, and on the install
sheet — so a global hotkey a plugin grabs is always visible and never a
surprise. You can also preview a plugin's search from the terminal without
installing it: see vee search.
Leaving a plugin open in a window
The search panel closes on the next click, which is the wrong shape when what you wanted was to watch something. Open in Window, in a plugin's own dropdown beside Refresh and Debug, opens the same surface as a window you can move to another display and leave open — or press the panel's keep open button in its top-right corner to promote the panel already in front of you.
A window is not a snapshot. It keeps updating on the plugin's own refresh
interval, so a one-second sparkline= really does move once a second — something
a Notification Center widget cannot do, since WidgetKit floors refresh at five
minutes. It works for any plugin, with no <vee.*> declaration required.
- One window per plugin, as many plugins at once as you like. Opening a plugin that already has a window brings that window forward instead of stacking a second one.
- Pin or unpin with the button in the title bar. A pinned window floats above other apps, follows you across Spaces, and stays visible over a full-screen app; an unpinned one behaves like an ordinary window. New windows are pinned, and Vee remembers your choice per plugin until it quits.
- Find them again under Detached Windows in Vee's own menu, which lists every open window and brings one to the front. (Vee has no Dock icon, so this and the plugin's hotkey are the reliable ways back to a window you have unpinned and covered.)
- When a plugin stops reporting — disabled, removed, or erroring — its window keeps the last output on screen and says it is stale, rather than quietly freezing on a number that looks current.
Windows are per-session: they do not reopen after you quit Vee.
Everything the dropdown renders appears in the window — nested submenus,
separators, section headers, colors and ANSI, icons, and the full rich-row family
(progress=, sparkline=, pie=/donut=/stackedbar=, toggle=, slider=).
A toggle= or slider= is live in the row: change it and the plugin's command
runs, exactly as it does from the popover.
Two things are deliberately not reproduced, because they only mean anything
inside an open menu. An ⌥ alternate is shown as an ordinary row of its own
rather than something you hold a modifier to reveal — so it is visible and
clickable, which is more than the dropdown offers. Per-row key= equivalents are
not bound.
Cross-plugin search ("Search All Plugins")
The panel above searches one plugin at a time. Search All Plugins…, in
Vee's main menu-bar menu rather than any single plugin's, merges every
enabled plugin's current menu into one panel — regardless of whether a
plugin opted into <vee.filter> — with each row breadcrumb-prefixed by its
plugin's name (itself part of the fuzzy match, so typing a plugin's name
surfaces its rows). Selecting a row still runs that row's own plugin's
action, never a different plugin's. It has its own opt-in global hotkey, off
by default with no preset combination — set one in Vee's General settings.
Widgets
Plugins can also render as native desktop and Notification Center widgets — automatically from their menu-bar line, or as a rich widget card they print themselves. That surface has its own page: Widgets.
Metadata headers
Put <xbar.*> / <swiftbar.*> tags anywhere in the file (usually in a comment block near the top). They are scanned regardless of the comment syntax, so they work in any language.
| Tag | Purpose | |
|---|---|---|
<xbar.title> | Human-readable plugin name. | |
<xbar.version> | Plugin version. | |
<xbar.author> | Author name. | |
<xbar.author.github> | Author's GitHub handle. | |
<xbar.desc> | One-line description. | |
<xbar.image> | Preview image URL. | |
<xbar.dependencies> | Comma-separated tools the plugin needs (e.g. python3,jq). | |
<xbar.abouturl> | A link to the plugin's homepage. | |
<xbar.var> | A typed, user-editable preference — see Preferences. | |
<swiftbar.schedule> | A cron schedule (one or more, ` | `-separated). |
<swiftbar.type>streamable</swiftbar.type> | Marks the plugin as a long-running streaming plugin. | |
<swiftbar.runInBash> | Whether to run the script through bash. | |
<swiftbar.refreshOnOpen> | Re-run the plugin each time its menu opens. | |
<swiftbar.environment> | Inline environment variables, e.g. [VAR1=a, VAR2=b]. | |
<swiftbar.hideAbout> | Hide the default "About" item. | |
<swiftbar.hideRunInTerminal> | Hide the "Run in Terminal…" item. | |
<swiftbar.hideLastUpdated> | Hide the "Updated…" timestamp item. | |
<swiftbar.hideDisablePlugin> | Hide the "Disable Plugin" item. | |
<swiftbar.hideSwiftBar> | Hide the app (Vee) submenu. | |
<swiftbar.persistentWebView> | Keep a webview= window alive across refreshes instead of recreating it. |
The <swiftbar.*> tags use the same names as their <xbar.*> counterparts where they overlap.
Vee-native tags (<vee.*>)
Vee adds a few tags of its own. All are opt-in — omit them for the classic behavior.
| Tag | Purpose |
|---|---|
<vee.filter> | <vee.filter>true</vee.filter> opts the dropdown into the searchable filter panel. |
<vee.shortcut> | <vee.shortcut>cmd+shift+k</vee.shortcut> binds a global hotkey that opens the search panel from anywhere. |
<vee.surface> | menu (default) / both / widget — which output surface(s) the plugin serves. See Widgets. |
<vee.timeout> | <vee.timeout>90</vee.timeout> overrides the default 30s execution timeout for this plugin. Accepts a plain number of seconds or a duration token (ms/s/m/h/d, same format as filename intervals), e.g. <vee.timeout>2m</vee.timeout>. Clamped to 1s–1h. |
<vee.capabilities>, <vee.network>, <vee.secrets>, <vee.filesystem.read> / <vee.filesystem.write>, <vee.exec> | Declare the plugin's trust footprint. |
SF Symbols
Use Apple's SF Symbols as icons or inline glyphs:
- As an item icon:
Some item | sfimage=bolt.fill - Colored:
Battery | sfimage=battery.100 sfcolor=green - Inline in text:
Status :checkmark.circle: | symbolize=true
Browse names with Apple's SF Symbols app.
ANSI color
If your tool emits ANSI escape codes (many CLIs do), Vee interprets them by default — no parameter needed:
echo -e "\033[32mOK\033[0m"Pass ansi=false to turn this off and show the raw escape codes as text instead.
Markdown
Render inline Markdown with md=true:
**Bold** and _italic_ | md=trueStreaming
A streaming plugin stays running and pushes updates instead of being re-run on a timer. Mark it with <swiftbar.type>streamable</swiftbar.type> and print a ~~~ line to signal "the menu that follows replaces the current one." Each block between ~~~ separators is a full menu render. Vee restarts the process with backoff if it exits.
#!/bin/bash
# <swiftbar.type>streamable</swiftbar.type>
while true; do
echo "~~~"
echo "⏱ $(date +%T)"
sleep 1
doneCron schedules
Instead of (or in addition to) a filename interval, schedule a plugin with a 5-field cron expression:
# <swiftbar.schedule>0 9 * * 1-5</swiftbar.schedule>The fields are minute hour day-of-month month day-of-week, supporting *, single values, lists (a,b), ranges (a-b), and steps (*/n). Day-of-week is 0–6 (0 = Sunday). Multiple schedules can be separated with |.
Environment variables Vee injects
Every plugin run inherits your shell environment plus these variables:
xbar compatibility
XBARDarkMode—truewhen the system is in dark mode, elsefalse.
SwiftBar compatibility
SWIFTBAR—1when running under Vee's SwiftBar-compatible runtime.SWIFTBAR_VERSION,SWIFTBAR_BUILD— the app version.SWIFTBAR_PLUGINS_PATH— the plugins directory.SWIFTBAR_PLUGIN_PATH— the absolute path of this plugin.SWIFTBAR_PLUGIN_CACHE_PATH,SWIFTBAR_PLUGIN_DATA_PATH— per-app cache and data directories.OS_APPEARANCE—DarkorLight.OS_VERSION_MAJOR,OS_VERSION_MINOR,OS_VERSION_PATCH— the macOS version.
Vee-native
VEE—1.VEE_VERSION— the app version.VEE_PLUGIN_PATH— the absolute path of this plugin.VEE_PLUGIN_ID— this plugin's id (its filename); pass it asplugin=toswiftbar://notifyfor an actionable alert (see URL actions).VEE_CONTROL_VALUE— set only on a re-invocation triggered by an interactivetoggle=/slider=item, carrying the committed value.
Any values from the plugin's declared <xbar.var> preferences are also injected as environment variables (they take precedence over the above).
Worked examples
1. Bash — CPU usage with a submenu
Filename: cpu.5s.sh
#!/bin/bash
# <xbar.title>CPU</xbar.title>
# <xbar.desc>Shows CPU load in the menu bar.</xbar.desc>
# <xbar.author>You</xbar.author>
load=$(uptime | awk -F'load average:' '{print $2}' | awk -F, '{print $1}' | xargs)
echo "CPU $load | sfimage=cpu"
echo "---"
echo "Top processes"
top -l 1 -o cpu -n 5 -stats command,cpu | tail -n 5 | while read -r line; do
echo "--$line | font=Menlo"
done
echo "---"
echo "Activity Monitor | bash=/usr/bin/open param0=-a param1='Activity Monitor' terminal=false"
echo "Refresh | refresh=true"2. Python — GitHub notifications with a preference and a link
Filename: github.10m.py
#!/usr/bin/env python3
# <xbar.title>GitHub Notifications</xbar.title>
# <xbar.desc>Unread GitHub notifications count.</xbar.desc>
# <xbar.dependencies>python3</xbar.dependencies>
# <xbar.var>string(GITHUB_TOKEN=): A GitHub personal access token.</xbar.var>
# <vee.network>api.github.com</vee.network>
# <vee.secrets>GITHUB_TOKEN</vee.secrets>
import os, json, urllib.request
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
print("GH ⚙️")
print("---")
print("Set a token in Vee settings")
raise SystemExit
req = urllib.request.Request(
"https://api.github.com/notifications",
headers={"Authorization": f"Bearer {token}"},
)
items = json.load(urllib.request.urlopen(req))
print(f"GH {len(items)} | sfimage=bell")
print("---")
for n in items[:10]:
title = n["subject"]["title"]
print(f"{title} | href=https://github.com/notifications")
print("---")
print("Refresh | refresh=true")This example also declares a preference (<xbar.var>) and its trust footprint (<vee.network>, <vee.secrets>).
3. Bash — streaming clock
Filename: clock.sh (no interval — streaming drives the updates)
#!/bin/bash
# <xbar.title>Clock</xbar.title>
# <swiftbar.type>streamable</swiftbar.type>
while true; do
echo "~~~"
echo "🕒 $(date +%H:%M:%S)"
echo "---"
echo "$(date '+%A, %B %d')"
sleep 1
donePublishing your plugin
Vee reads plugins from a folder, so "distributing" one can be as simple as sending someone a file. There are three routes, in increasing order of reach.
1. Share the file
Any executable with an interval in its filename is a complete, self-contained plugin. Someone drops it in their plugins folder and it runs — no packaging, no manifest, no install step.
Two courtesies make a shared plugin pleasant to receive:
- Ship it without the executable bit, so the recipient reads the source
before marking it
+x. The bundled examples do exactly this. - Fill in the metadata.
<xbar.title>,<xbar.desc>,<xbar.author>, and<xbar.dependencies>are what Vee shows about your plugin, and<xbar.dependencies>is what tells someone why it does not work on their machine.
2. Submit it to the catalog
Vee's Discover window browses the shared
matryer/xbar-plugins catalog, so a
plugin accepted there reaches xbar, SwiftBar, and Vee users alike.
To propose one for Vee's own catalog and gallery, open an issue using the
Plugin submission template in the
Vee repository. Include what
the plugin does, its language and dependencies, its declared <vee.*>
capabilities, and a link to the source.
Submissions are reviewed against the trust model, and this is the part worth
taking seriously: declarations must match behavior. A plugin whose
<vee.network> list omits a domain it actually contacts will be declined, because
the whole point of the declaration is that a user can rely on it when deciding
whether to run un-sandboxed code. Declaring more than you use is fine; declaring
less is not. See the trust model.
3. Run your own store
For internal or team plugins that should not be public, Vee can read a custom
store — a GitHub repo, a static HTTP host, or an air-gapped file:// mirror —
and show it in Discover alongside (or instead of) the public catalog, installing
through the same trust gate.
That is the enterprise path, including private repositories, integrity checks, and MDM-managed configuration: see Custom plugin stores.
Before you publish, whichever route
vee lintexits non-zero on authoring mistakes — wire it into CI if the plugin lives in a repository. See Debugging and testing plugins.- Check the plugin degrades gracefully with no token, no network, and a missing dependency. A plugin that prints a useful "not configured" row beats one that prints a stack trace into someone's menu bar.
- Declare your
<vee.*>capabilities honestly, even outside the catalog — they are what the trust summary shows at install.
See also
- Trust model — declare what your plugin accesses.
- Preferences — declare typed settings and secrets.
- Writing plugins with an LLM — the format in one file, the schemas, and the
vee lintloop. - Custom plugin stores — host your own catalog of internal plugins.
- Widgets — render a plugin on the desktop and in Notification Center.
- Debugging and testing plugins — preview, watch, and lint a plugin while you write it.
- CLI and URL actions — trigger refresh/notify from a plugin.
- Troubleshooting — when a plugin does not appear or errors.