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 (app running)

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.

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).

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 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.

This service is also the top level of the Start KiwiDesk control in Settings ▸ General: At Login + Restart on Crash loads exactly this LaunchAgent, so running service start and choosing that level are equivalent, and service status reports the same state — one live source of truth, no second store. The middle level, At Login, is instead the SMAppService login item (visible in System Settings ▸ Login Items), a separate path that launches KiwiDesk at login without crash supervision. Both launch at login; the single-instance lock keeps that to one process, so they never conflict (see Accepted limitations). 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. These strings are the login item’s only appearance in CLI output.

CategoryCommandArguments
Navigationfocusleft|right|up|down
swapleft|right|up|down
focus_spacespace id
move_to_spacespace id
move_to_space_and_followspace id
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
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_resize_feedbacktrue|false (default true) — alert sound when a resize hotkey can’t act
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
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, monitor_count, native_space, exec_running})
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_native_spacedesktop number, profile
Diagnosticsget_layout_info
list_monitors
debug_logmessage
Animationanimations.set_durationms (50–1000); persisted per-profile
animations.set_scroll_speedms (50–1000); scroll-layout focus speed, 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)
drag.set_ghost_border_alignmentinside|outside (default inside)
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)
drag.set_drop_zone_border_alignmentinside|outside (default inside)
drag.set_drop_zone_border_color / drag.set_drop_zone_fill_color#RRGGBB[AA]
drag.set_corner_radiuspt (default 16)
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
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_spacetrue|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_wrap_focustrue|false (default true — monocle is a carousel)
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). 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, grid, and floating reply “not supported” — from a hotkey that failure also plays the system alert sound (set_resize_feedback, default on; CLI/IPC callers stay silent).

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, native_space_change, window_created, window_destroyed, window_moved_to_space.

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"}}

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 native macOS desktop or a session restore), restored (deminiaturized).
  • window_destroyedclosed (a real close), minimized (only minimized; it will come back as restored), vanished (its native desktop was switched away; it returns as returned).

A native macOS Space switch thus fires a burst of vanished destroys and a burst of returned creates — filter on reason to ignore them. Caveat: a window closed while its desktop is off-screen already emitted vanished and never gets a corrective closed; consumers that filter vanished must also refresh their state on native_space_change.

window_moved_to_space fires when a window is explicitly moved to another virtual 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); 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"}}

native_space_change fires when the user switches native macOS Spaces (Mission Control desktops); its data carries the 1-based desktop number and the active profile:

{"event": "native_space_change",
"data": {"native_space": 2, "profile": "Creator Studio"}}

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