Skip to content

CLI & IPC Reference

One binary is both the app and the CLI:

Terminal window
kiwidesk # run the window manager
kiwidesk <command> [args...] # send a command to the app
kiwidesk help # local usage
kiwidesk --version # local version (no app needed)
kiwidesk list_commands # every command, grouped
kiwidesk help <name> # one command's arguments

The Homebrew cask puts it on your PATH as lower-case kiwidesk, which is what every example here uses. A source build produces .build/release/KiwiDesk instead — same commands, so substitute that path for kiwidesk throughout. The capitalized KiwiDesk elsewhere on this page is the Lua global, the config directory, or the product name — none of those change.

Installed from the .dmg? The app bundle carries the same executable — the CLI is not a separate program — so all the cask does is link it onto your PATH. Do that once yourself:

Terminal window
sudo mkdir -p /usr/local/bin
sudo ln -sf /Applications/KiwiDesk.app/Contents/MacOS/KiwiDesk \
/usr/local/bin/kiwidesk

The mkdir is not redundant: /usr/local/bin does not exist on a clean macOS that has never had Homebrew — which is exactly this paragraph’s reader — and ln fails with No such file or directory without it.

A symlink rather than a copy, deliberately. It resolves through to whatever is inside the bundle, so the CLI stays the version of the app you are running after KiwiDesk updates itself. A copy would not work at all: the executable’s signature is sealed to its bundle and macOS kills it the moment it runs from anywhere else, and it would no longer find the Sparkle framework it loads from alongside the app.

Another directory works if you would rather not use sudo, as long as it is one your shell already searches — note that ~/.local/bin is not on the macOS default PATH, so it needs adding first.

Commands are sent over a UNIX domain socket at ~/.config/KiwiDesk/KiwiDesk.sock. Exit code is 0 on success, 1 on error (message on stderr, data on stdout).

Data on stdout is JSON with its object keys sorted, so two captures of the same response can be diffed. It is indented when stdout is a terminal and compact — one line — when it is piped or redirected, which keeps it exactly what a script or jq already expects. subscribe is unaffected either way: its stream is newline-delimited JSON, one event per line, whatever stdout is.

Terminal window
kiwidesk --version # or -v; works without the app running

Prints <semantic version> (<short commit>), e.g. 0.1.0 (abc1234), or just the semantic version when the commit is unknown. Only a build produced by the release workflow knows its own commit — a commit cannot contain its own SHA, so a checked-in tree cannot name the one it becomes, and any build you make yourself prints the bare version. The same information is available over IPC/Lua as the version command — see the table below.

Terminal window
kiwidesk list_commands # every command, grouped
kiwidesk help scroll.set_anchor # one command in full
kiwidesk list_commands --json # the same, machine-readable

Both work without the app running. The listing describes the API the binary was built with — no app state goes into it — and that binary is the CLI, so nothing is asked over the socket. If an older KiwiDesk is running while a newer kiwidesk is first on your PATH, the listing describes the newer one; they ship as a single binary, so that is a half-finished install rather than something to reason about.

list_commands prints one block per group: the KiwiDesk table first (the commands a keybinding usually names), then each layout and bar namespace. Every line carries the command’s arguments and a one-line summary:

scroll
set_anchor <anchor> Sets where the focused …
set_orientation <orientation> Sets whether columns scroll …
set_slot_size_override <space> <size> Overrides the slot …

Required arguments are in <angle brackets>, optional ones in [square brackets]. A command the CLI cannot reach is marked: (lua only) for the entry points that live on the Lua table alone (bind, on, exec, …), (cli only) for subscribe.

Naming one command prints its full signature, including the legal values of an enum argument and the Swift type they come from:

$ kiwidesk help scroll.set_anchor
scroll.set_anchor <anchor>
Sets where the focused window comes to rest in the viewport.
arguments:
anchor choice
center | start | end | follow
(ScrollingParams.Anchor)
lua: scroll.set_anchor(anchor)
cli: kiwidesk scroll.set_anchor <anchor>

Those values are read from the decoder that accepts them, so the listing cannot fall behind the code — that is the whole point of keeping this data in APIReference rather than in prose.

A misspelled name fails with a suggestion and exit code 1:

$ kiwidesk help focsu
error: unknown command: focsu (did you mean focus?)

Unlike the did-you-mean hint on an unknown command, this one will point at a Lua-only name: you are looking a name up, not invoking it.

Text or JSON. A terminal gets the text above; a pipe or a redirect gets JSON. --json forces JSON either way. An unrecognised option is an error, not a silent no-op.

The JSON shape changed, and nothing preserves the old one. list_commands used to return a flat array of 262 name strings; it now returns {"commands": <count>, "groups": [...]}, each group carrying one object per command — name, qualified_name, group, command, channel, summary, aliases, and an arguments array whose enum entries add values. A script that read the old array needs updating; a command’s output is not a stored value, so no compatibility shape is owed for one.

The Swift type an enum’s values were read from is deliberately not a JSON field. It is printed in the terminal rendering, where it helps a person find the decoder, but publishing it would make an internal symbol part of this command’s output — and those get renamed freely. values is what answers “what may I send”.

Bare kiwidesk help (and --help / -h) still prints the short usage block. Add a name, or --json, to get the API instead.

Terminal window
kiwidesk service start # LaunchAgent: run at login,
# restart after crashes
kiwidesk service stop
kiwidesk service restart
kiwidesk service status # loaded? running? pid?

start bootstraps the agent when it isn’t loaded, and relaunches it when the job is loaded but idle — the state a quick-menu Quit leaves behind (the app exits cleanly, so KeepAlive doesn’t restart it, but the launchd job stays registered). It no-ops with KiwiDesk service is already running only when a process is actually running. stop prints KiwiDesk service is not running cleanly when nothing is loaded. restart boots the job out and back in; when nothing was loaded it reports KiwiDesk service was not running — started it rather than claiming to have restarted something that wasn’t there. status reports the loaded/running state and the pid. A real launchctl failure exits non-zero; the ordinary already-running / not-running cases exit 0. start while KiwiDesk is already running loads the agent, whose RunAtLoad spawns one supervised launch; it finds the instance lock held, brings the running copy forward once — taking focus from your terminal — and exits cleanly.

This service is the only way to get crash supervision (#1071). Settings offers no switch for it: it is a second launcher, and running it beside the login item means two mechanisms starting KiwiDesk at login, which is a thing to understand rather than a checkbox to tick. Settings ▸ General’s Start at login is the SMAppService login item (visible in System Settings ▸ Login Items) and nothing else — it never touches this agent, and this agent never touches it.

Both launch at login. The single-instance lock keeps that to one process, so they never fight over your windows — but only the launch that wins is supervised, so running both means supervision is a coin flip (see Accepted limitations). Run one: this service if you want crash restart, the login item if you do not. While the service is loaded, the Settings switch shows as on and inert, saying so. To keep the two visible to each other, service status adds a login item: line reporting the login-item state, and service start prints a note when the login item is also on — telling you two mechanisms will start KiwiDesk, and to run one. These strings are the login item’s only appearance in CLI output.

KiwiDesk writes its diagnostic lines to the macOS unified log under its own subsystem, at default level and with the text public — so the shipped app’s log is readable on any Mac with no debug build and no extra permission. To hand it over with a bug report, export the last stretch to a file:

Terminal window
/usr/bin/log show --last 15m \
--predicate 'subsystem == "com.kiwicanopy.kiwidesk"' \
--style compact > ~/Desktop/kiwidesk-log.txt

Reach back to just before the problem happened — --last 15m, --last 2h, or --start "2026-09-02 09:40:00" for an exact window — and attach the file to the issue rather than pasting fragments. /usr/bin/log, spelled out, because a shell alias named log is common. To watch live while reproducing:

Terminal window
/usr/bin/log stream --predicate 'subsystem == "com.kiwicanopy.kiwidesk"' --style compact

The subsystem filter keeps the file to what KiwiDesk itself wrote — exact, and public text — which is what an attachable report wants. It deliberately excludes other processes’ lines (the privacy daemon’s Accessibility verdicts, the WindowServer); a maintainer who needs those widens to a word filter on “KiwiDesk”, which also catches unrelated apps mentioning the name. (kiwidesk debug_log [message] WRITES a marker line into this same log, useful to bracket a repro; it exports nothing.)

CategoryCommandArguments
Navigationfocusleft|right|up|down
swapleft|right|up|down
focus_spacespace id
move_to_spacespace id
move_to_space_and_followspace id
focus_desktopDesktop number (Mission Control’s)
move_to_desktopDesktop number, [space id] — moves the focused window, you stay; the Space it joins when it lands, if given
move_to_desktop_and_followDesktop number, [space id] — moves the focused window, switches there, and leaves keyboard focus on the window; the Space it joins when it lands, if given
move_space_to_displayspace id, display index or name
pin_space_to_displayspace id, display index or name
create_spacespace id, [mode]
delete_spacespace id
Windowmake_floating
make_tiled
make_auto
toggle_floating
make_sticky— (sticky on every monitor)
make_display_sticky— (sticky on this monitor only)
make_unsticky
toggle_sticky
toggle_display_sticky
override_sticky_reachon|off|auto — pins the focused sticky window’s Desktop reach against sticky.set_desktop_reach (auto clears the pin)
resizex|y, delta (px)
move_to_trackprev|next — move window to the adjacent track (track spaces)
Launchpull_or_spawnapp bundle id (e.g. com.apple.safari) — a repeat press while its window is focused cycles the app’s windows
spawn_newapp bundle id
Systemset_mode[space,] mode
set_mouse_resizelayout|snap_back
mouse.set_follows_focustrue|false (default false) — warp the pointer to the newly-focused window
set_gap_globalsize
set_gap_overridespace, size
set_min_window_sizept (default 300)
set_resize_steppt (default 50) — Grow/Shrink magnitude
set_refusal_soundtrue|false (default false) — add the system alert sound to a blocked action’s pill
set_swap_skips_cascadetrue|false (default true) — swap from a pile targets the outside neighbor
set_float_nudgetrue|false (default true) — shove a window toward center when it toggles to floating
sticky.set_desktop_reachtrue|false (default true) — sticky windows follow you across macOS Desktops (needs the window-management bridge)
set_fallback_spacespace id ("" clears) — rehome target on profile switch
set_space_iconspace id, icon (SF Symbol|emoji|char; "" clears)
quit.set_layoutgrid (default) — how windows are spread on quit
quit.set_grid_target_depth1–20 (default 5) — quit-grid density target (windows per cell before the grid grows)
get_state— (returns {active_space, spaces, windows, away_windows, monitor_count, desktop, exec_running}; desktop is the main screen’s current Desktop; away_windows lists the windows on Desktops no screen shows, each with id, app, bundle_id, space_id and desktop, and each space object carries its own away_windows ids)
reload_config
version— (returns {version, commit})
Profilessave_profilename (updates in place when it exists)
load_profilename
delete_profilename
set_default_profilename (its screen count’s fallback)
list_profiles
get_profile_status— (returns {name, standard, isDirty})
bind_profile_to_desktopDesktop number, profile (fires when that Desktop becomes current on the main screen and the profile is saved for the connected screen count)
Diagnosticsget_layout_info
list_monitors
debug_logmessage
Animationanimations.set_durationms (50–1000); persisted per-profile
animations.set_scroll_durationms (50–1000); scroll-layout focus-shift duration, persisted per-profile
animations.set_on_space_changetrue|false (default false)
animations.set_on_scrollingtrue|false (default true)
animations.set_on_window_resizetrue|false (default true)
animations.set_on_window_swaptrue|false (default true)
animations.set_on_relayouttrue|false (default true)
animations.set_size_policysmooth (default)|mid_slide; size policy (#47, #593), Lua-only, not persisted
animations.set_size_rateHz (1–120; 0 = per-tick default); throttles smooth size-sets both directions, Lua-only, not persisted
Sleep/Wakeenable_wake_restoretrue|false
set_wake_restore_delayms
Dragdrag.set_ghost_enabledtrue|false
drag.set_ghost_border / drag.set_ghost_filltrue|false
drag.set_ghost_border_widthpt (default 5, Lua-only per stroke)
drag.set_ghost_border_alignmentinside|outside (default inside, Lua-only)
drag.set_ghost_border_color / drag.set_ghost_fill_color#RRGGBB[AA]
drag.set_drop_zone_enabledtrue|false
drag.set_drop_zone_border / drag.set_drop_zone_filltrue|false
drag.set_drop_zone_border_widthpt (default 5, Lua-only per stroke)
drag.set_drop_zone_border_alignmentinside|outside (default inside, Lua-only)
drag.set_drop_zone_border_color / drag.set_drop_zone_fill_color#RRGGBB[AA]
drag.set_corner_radiuspt (default 16, numeric range Lua-only)
Stackstack.promote / stack.demote
stack.set_master_countn
stack.set_master_ratio0.1–0.9
stack.set_overflow_stylecascade_overflow|cascade_all
stack.set_stack_positiontop|right|bottom|left (default right; derives the stack’s lineup)
stack.set_master_orientationvertical|horizontal (default horizontal)
stack.set_new_window_placementplacement¹ (default first)
BSPbsp.set_strategylongest_side|alternating (default alternating)
bsp.set_ratio_h0.1–0.9 (side-by-side splits)
bsp.set_ratio_v0.1–0.9 (stacked splits)
bsp.set_new_window_placementplacement¹ (default after_focused)
Scrollingscroll.set_slot_sizepx, "NN%", or 0 (auto)
scroll.set_anchorcenter|start|end|follow (default follow)
scroll.set_orientationhorizontal|vertical
scroll.set_new_window_placementplacement¹ (default after_focused)
scroll.set_wrap_focustrue|false (default false)
Gridgrid.set_typedynamic|rigid
grid.set_fill_empty_cellstrue|false
grid.set_split_directionhorizontal|vertical
grid.set_dimensionscolumns, rows (upper bound in dynamic)
grid.set_auto_sizetrue|false (default false; dims from screen)
grid.set_new_window_placementplacement¹ (default last)
Monoclemonocle.set_orientationhorizontal|vertical
monocle.set_hide_stylestack|park (default stack; park hides unfocused windows at the stash corner)
monocle.set_wrap_focustrue|false (default false, matching scrolling/track)
monocle.set_new_window_placementplacement¹ (default first)
Tracktrack.swapprev|next — swap the focused window’s whole track with the adjacent one
track.set_axisvertical|horizontal (default vertical = columns)
track.set_limitn (0 = automatic; n>0 pins a cap and turns automatic off)
track.set_auto_trackstrue|false (default true)
track.set_new_windowown_track|focused_track (default focused_track)
track.set_new_window_positionplacement¹ (default first) — where within the new_window choice
track.set_overflow_stylecascade_all|cascade_overflow (default cascade_all for track)
track.set_wrap_focustrue|false (default false)
Spawnset_new_window_placement_overridespace id, placement¹ (not track spaces — they follow track.set_new_window)

¹ placement: first\|last\|before_focused\|after_focused

The table lists each layout global once. Every layout global has a per-space _override twin (e.g. bsp.set_ratio_h_override, scroll.set_slot_size_override) that takes a leading space id, value and shadows the global for that space only.

resize adapts to the active layout and is per-axis (#56). A floating focused window resizes itself directly in any mode (width for x, height for y, floored at min_window_size), splitting the delta between both edges and pinning one that is already against the screen edge or a bar (#1091). “Floating” is the effective float since #1184: the window’s own flag, or any window in a floating-layout space, which places nothing. A focused window in native full screen is refused ahead of every layout, whatever its float state, with the focused window is fullscreen (#1298): nothing is written and no neighbour moves; the pill and sound rule below apply to it as to the monocle/grid reply. For tiled windows: in BSP, x moves the side-by-side split ratio and y the stacked one, independently, each in the direction that grows the focused window’s region (#122). Stack is focus-aware too (#67) and arrangement-aware (#222): the split axis (x for a left/right stack zone, y for top/bottom) moves the master/stack split in the direction that grows the focused window; the focused zone’s own axis grows that window’s share of its zone (session-scoped weights, reset on relaunch). An axis matching neither fails with the cue — so a master zone lined up along the split axis has no reachable per-window shares (accepted, see design-decisions). Scrolling resizes the slot along its own scroll axis for either x or y. In a track space the axis across the tracks resizes the focused window’s track, the axis along them its share within the track (#128; session-scoped weights too). monocle and grid reply “not supported” — that failure flashes a pill on the focused window whatever issued it, and adds the system alert sound when set_refusal_sound is on (default off) and a hotkey was what fired: a CLI or IPC caller reads the error reply and never hears one.

ignore_rules is declarative config, not a session command. Edit ignore_rules = { "bundle.id" } in init.lua, then apply it with:

Terminal window
kiwidesk reload_config

For GUI-managed setups, put the array at the root ignore_rules key in gui.json, then run kiwidesk reload_config. Matching apps disappear from KiwiDesk state and emit no window events. Removing an id and reloading makes its windows manageable again.

Those declarations are the global base. A profile JSON may carry a sparse ignore_rules object: true adds an id and null removes an inherited one while that profile is active. load_profile applies the resolved rules immediately over either a Lua- or GUI-owned base.

External tools subscribe over the same socket:

Terminal window
kiwidesk subscribe # all events
kiwidesk subscribe space_change layout_change

Each event is one JSON line:

{"event": "space_change",
"data": {"space_id": "3", "layout_mode": "bsp",
"window_count": 4}}

Events: space_change, layout_change, focus_change, monitor_change, desktop_change, window_created, window_destroyed, window_moved_to_space, layer_change.

Give no arguments and you get every event; give arguments and the filter is honoured exactly. An argument that names no event is dropped, and the subscription still succeeds — so the confirmation line lists what was dropped:

{"status": "success", "data": {"unknown": ["space_chnage"]}}

A non-string argument appears there as <non-string>: it has no name to report back, but it was dropped just the same. The same names, truncated after the first few, go to the application log (viewable in Console.app).

Subscribe to nothing but unrecognised names and the stream stays silent — an empty filter is not the same request as no filter, and unknown says why nothing is arriving.

Every window event carries bundle_id — the stable identity key (the one app rules and pull_or_spawn match on) — next to the locale-dependent display app name. It is JSON null for unbundled processes; the Lua callback receives it as the trailing positional argument, "" when unknown.

focus_change data carries window_id, app, bundle_id, and title — but the Lua callback receives only window_id, app, bundle_id positionally, so the window title is available on the socket stream but not to a Lua handler:

{"event": "focus_change",
"data": {"window_id": 4711, "app": "Ghostty",
"bundle_id": "com.mitchellh.ghostty",
"title": "~/src — zsh"}}

The window lifecycle events fire even when focus does not change, so bars can drop stale icons immediately:

{"event": "window_created",
"data": {"window_id": 4711, "app": "Ghostty",
"space_id": "2", "reason": "new",
"bundle_id": "com.mitchellh.ghostty"}}
{"event": "window_destroyed",
"data": {"window_id": 4711, "app": "Ghostty",
"space_id": "2", "reason": "closed",
"bundle_id": "com.mitchellh.ghostty",
"desktop": null}}

window_created carries the space the window was placed in (app_rules included); window_destroyed carries the space the window disappeared from — its own space, even when that space is not active.

These events track the visible window set, not app lifecycle; the reason field says why the set changed:

  • window_creatednew (a genuinely new window), returned (back from another macOS Desktop, from an app that was unhidden, or from a session restore), restored (deminiaturized).
  • window_destroyedclosed (a real close), minimized (only minimized; it will come back as restored), hidden (its app was hidden, with ⌘H or by hiding itself as its last window closed; the window is untouched and comes back as returned), vanished (the window is on a macOS Desktop no screen is showing; it returns as returned). A vanished payload also carries desktop, the Mission Control number of the Desktop holding the window, where the Desktop can be read; every other reason — and a Mac without SkyLight — sends null.

A macOS Desktop switch thus fires a burst of vanished destroys and a burst of returned creates — filter on reason to ignore them. The reason is read off the WindowServer, not a timer: a window it still hosts on an unshown Desktop is vanished, one it hosts nowhere is closed, whenever the destroy lands. A window closed while its Desktop is off-screen is reported closed when KiwiDesk next reads the Desktops — at the next Desktop switch, or within about five seconds while any window is away — so a consumer sees two destroys for that window, vanished then closed, and refreshing on desktop_change remains the safe pattern.

window_moved_to_space fires when a window is explicitly moved to another space (move_to_space, with or without follow, or a drag onto another display — the live crossing emits as the membership moves, so a drag pulled back before release emits once per crossing). A move_to_desktop onto a Desktop that lives on another screen emits it too: the window joins the space that screen shows, or the layout would carry it back to the screen it left (#1010). move_to_desktop with a named Space emits it as well when that Desktop is the one its screen is already showing — to_space_id is the Space you named. When the Desktop is hidden there is no window_moved_to_space at all: the window is reported vanished and comes back with window_created, reason: returned, already in the named Space (#1150). Bulk reassignments (profile load, session restore) stay silent:

{"event": "window_moved_to_space",
"data": {"window_id": 4711, "app": "Spotify",
"from_space_id": "1", "to_space_id": "3",
"bundle_id": "com.spotify.client"}}

desktop_change fires when the visible Desktop changes on any screen — a swipe, Mission Control, or a focus_desktop / move_to_desktop_and_follow command; its data carries the 1-based Desktop number now current on the screen that switched, that screen’s positional number (monitor: 1 is the main screen, secondaries follow left to right — the same 1-based positional numbering a display argument takes), and the active profile:

{"event": "desktop_change",
"data": {"desktop": 2, "monitor": 1,
"profile": "Creator Studio"}}

With “Displays have separate Spaces” on, each screen switches Desktops on its own, so watch monitor to tell them apart: only a switch on the main screen (monitor: 1) selects a profile. A secondary screen’s swipe reports its Desktop and moves that screen onto the Space its arriving Desktop should show — which emits space_change too when that screen is the one holding the active Space. With the option off, or with a single screen, monitor is always 1.

layer_change fires when the active keyboard layer changes — a switch_layer call, a config reload returning you to default, a profile switch — and never when a switch names the layer already active. It carries the previous and the new layer names, default included, so a status bar can show the active layer without polling:

{"event": "layer_change",
"data": {"from_layer": "default", "to_layer": "resize"}}

Anything that can write to a UNIX socket can drive KiwiDesk — newline-delimited JSON, one request per line:

Terminal window
printf '{"command":"set_mode","args":["1","grid"]}\n' \
| nc -U ~/.config/KiwiDesk/KiwiDesk.sock

Response:

{"status": "success"}
{"status": "error", "error": "unknown command: ..."}

Unknown commands come back with a did-you-mean suggestion when a close match exists.

© 2026 KiwiCanopy