CLI & IPC Reference
One binary is both the app and the CLI:
kiwidesk # run the window managerkiwidesk <command> [args...] # send a command to the appkiwidesk help # local usagekiwidesk --version # local version (no app needed)kiwidesk list_commands # every command, groupedkiwidesk help <name> # one command's argumentsThe Homebrew cask puts it on your
PATHas lower-casekiwidesk, which every example here uses. A source build produces.build/release/KiwiDeskinstead — same commands, so substitute that path forkiwideskthroughout. The capitalizedKiwiDeskelsewhere on this page is the Lua global, the config directory, or the product name.
Installed from the .dmg? The app bundle carries the same
executable; the cask only links it onto your PATH. Do that once
yourself:
sudo mkdir -p /usr/local/binsudo ln -sf /Applications/KiwiDesk.app/Contents/MacOS/KiwiDesk \ /usr/local/bin/kiwideskThe mkdir is there because /usr/local/bin may not exist on a
Mac that has never had Homebrew. Link, never copy: a copy fails
its signature check.
A directory you own works without sudo as long as your shell
already searches it; ~/.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; data goes to stdout, the error message — and, on some
successes, a note — to stderr.
Data on stdout is JSON with its object keys sorted, indented
when stdout is a terminal and compact — one line — when it is
piped or redirected. subscribe is unaffected either way: its
stream is newline-delimited JSON, one event per line, whatever
stdout is.
Version
Section titled “Version”kiwidesk --version # or -v; works without the app runningPrints <semantic version> (<short commit>), e.g. 0.1.0 (abc1234), or just the semantic version when the commit is
unknown: only a release build knows its commit, so a build you
make yourself prints the bare version. The same information is
available over IPC/Lua as the version command — see the table
below.
Discovering Commands
Section titled “Discovering Commands”kiwidesk list_commands # every command, groupedkiwidesk help scroll.set_anchor # one command in fullkiwidesk list_commands --json # the same, machine-readableBoth work without the app running: the listing describes the API the binary was built with, carries no app state, and asks nothing over the socket.
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_anchorscroll.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.
A misspelled name fails with a suggestion and exit code 1:
$ kiwidesk help focsuerror: 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.
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.
list_commands 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.
The Swift type an enum’s values were read from is not a JSON
field; it appears in the terminal rendering only. 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.
Service Control
Section titled “Service Control”kiwidesk service start # LaunchAgent: run at login, # restart after crasheskiwidesk service stopkiwidesk service restartkiwidesk 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. 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. 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),
and Settings offers no switch for it. 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, but only the launch that wins is supervised, so
running both makes supervision 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. 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, saying that two mechanisms will
start KiwiDesk and to run one. These strings are the login item’s
only appearance in CLI output.
Exporting the Log
Section titled “Exporting the Log”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 attach it to a bug report, export the last stretch to a file:
/usr/bin/log show --last 15m \ --predicate 'subsystem == "com.kiwicanopy.kiwidesk"' \ --style compact > ~/Desktop/kiwidesk-log.txtReach 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 whole file to the issue. Spell out
/usr/bin/log: a shell alias named log is common. To watch
live while reproducing:
/usr/bin/log stream --predicate 'subsystem == "com.kiwicanopy.kiwidesk"' --style compactkiwidesk debug_log [message] writes a marker line into this
same log — one before and one after a repro brackets it; it
exports nothing.
Commands
Section titled “Commands”| Category | Command | Arguments |
|---|---|---|
| Navigation | focus | left|right|up|down |
swap | left|right|up|down | |
focus_space | space id | |
move_to_space | space id | |
move_to_space_and_follow | space id | |
focus_desktop | Desktop number (Mission Control’s) | |
move_to_desktop | Desktop number, [space id] — moves the focused window there; you stay. space id names the Space it lands in | |
move_to_desktop_and_follow | Desktop number, [space id] — moves the focused window there, switches with it and keeps keyboard focus on it. space id names the Space it lands in | |
move_space_to_display | space id, display index or name | |
pin_space_to_display | space id, display index or name | |
create_space | space id, [mode] | |
delete_space | space id — see Deleting a Space | |
| Window | make_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_reach | on|off|auto — pins the focused sticky window’s Desktop reach against sticky.set_desktop_reach (auto clears the pin) | |
resize | x|y, delta (px) | |
move_to_track | prev|next — move window to the adjacent track (track spaces) | |
| Launch | pull_or_spawn | app bundle id (e.g. com.apple.safari) — a repeat press while its window is focused cycles the app’s windows |
spawn_new | app bundle id | |
| System | set_mode | [space,] mode |
set_mouse_resize | layout|snap_back | |
mouse.set_follows_focus | true|false (default false) — warp the pointer to the newly-focused window | |
set_gap_global | size | |
set_gap_override | space, size | |
set_min_window_size | pt (default 300) | |
set_resize_step | pt (default 50) — Grow/Shrink magnitude | |
reset_layout_sizing | [space|all] returns the active space’s ratios, slot size and weights to what the profile set — or one space’s, or every space’s; structure stays | |
set_refusal_sound | true|false (default false) — add the system alert sound to a blocked action’s pill | |
set_swap_skips_cascade | true|false (default true) — swap from a pile targets the outside neighbor | |
set_float_placement | center (default) | keep — where a window lands when it toggles to floating | |
sticky.set_desktop_reach | true|false (default true) — sticky windows follow you across macOS Desktops (needs the window-management bridge) | |
set_fallback_space | space id ("" clears) — rehome target on profile switch | |
set_space_icon | space id, icon (SF Symbol|emoji|char; "" clears) | |
quit.set_layout | grid (default) — how windows are spread on quit | |
quit.set_grid_target_depth | 1–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}) | |
| Profiles | save_profile | name (updates in place when it exists; see Profile Monitor Sets) |
load_profile | name (see Profile Monitor Sets) | |
delete_profile | name | |
set_default_profile | name (its screen count’s fallback) | |
list_profiles | — | |
get_profile_status | — (returns {name, standard, isDirty}) | |
bind_profile_to_desktop | Desktop number, profile, screen fingerprints… (optional; scopes the binding to that setup) (fires when that Desktop becomes current on the main screen and the profile is saved for the connected screen count) | |
| Diagnostics | get_layout_info | — |
list_monitors | — | |
debug_log | [message] | |
| Animation | animations.set_duration | ms (50–1000); persisted per-profile |
animations.set_scroll_duration | ms (50–1000); scroll-layout focus-shift duration, persisted per-profile | |
animations.set_on_space_change | true|false (default false) | |
animations.set_on_scrolling | true|false (default true) | |
animations.set_on_window_resize | true|false (default true) | |
animations.set_on_window_swap | true|false (default true) | |
animations.set_on_relayout | true|false (default true) | |
animations.set_on_monocle_focus | true|false (default true); the Monocle focus card flip, persisted per-profile | |
animations.set_monocle_flip_duration | ms (100–1000, default 450); the flip’s turn, persisted per-profile | |
animations.set_size_policy | smooth (default)|mid_slide; size policy (#47, #593), Lua-only, not persisted | |
animations.set_size_rate | Hz (1–120; 0 = per-tick default); throttles smooth size-sets both directions, Lua-only, not persisted | |
| Sleep/Wake | enable_wake_restore | true|false |
set_wake_restore_delay | ms | |
| Drag | drag.set_ghost_enabled | true|false |
drag.set_ghost_border / drag.set_ghost_fill | true|false | |
drag.set_ghost_border_width | pt (default 5, Lua-only per stroke) | |
drag.set_ghost_border_alignment | inside|outside (default inside, Lua-only) | |
drag.set_ghost_border_color / drag.set_ghost_fill_color | #RRGGBB[AA] | |
drag.set_drop_zone_enabled | true|false | |
drag.set_drop_zone_border / drag.set_drop_zone_fill | true|false | |
drag.set_drop_zone_border_width | pt (default 5, Lua-only per stroke) | |
drag.set_drop_zone_border_alignment | inside|outside (default inside, Lua-only) | |
drag.set_drop_zone_border_color / drag.set_drop_zone_fill_color | #RRGGBB[AA] | |
drag.set_corner_radius | pt (default 16, numeric range Lua-only) | |
| Stack | stack.promote / stack.demote | — |
stack.set_master_count | n | |
stack.set_master_ratio | 0.1–0.9 | |
stack.set_overflow_style | cascade_overflow|cascade_all | |
stack.set_stack_position | top|right|bottom|left (default right; derives the stack’s lineup) | |
stack.set_master_orientation | vertical|horizontal (default horizontal) | |
stack.set_new_window_placement | placement¹ (default first) | |
stack.set_fill_when_alone | true|false (default true; off keeps the master zone even alone) | |
| BSP | bsp.set_strategy | longest_side|alternating (default alternating) |
bsp.set_ratio_h | 0.1–0.9 (side-by-side splits) | |
bsp.set_ratio_v | 0.1–0.9 (stacked splits) | |
bsp.set_new_window_placement | placement¹ (default after_focused) | |
| Scrolling | scroll.set_slot_size | px, "NN%", or 0 (auto) |
scroll.set_anchor | center|start|end|follow (default follow) | |
scroll.set_orientation | horizontal|vertical | |
scroll.set_new_window_placement | placement¹ (default after_focused) | |
scroll.set_wrap_focus | true|false (default false) | |
scroll.set_fill_when_alone | true|false (default true; off keeps the slot size even alone) | |
| Grid | grid.set_type | dynamic|rigid |
grid.set_fill_empty_cells | true|false | |
grid.set_split_direction | horizontal|vertical | |
grid.set_dimensions | columns, rows (upper bound in dynamic) | |
grid.set_auto_size | true|false (default false; dims from screen) | |
grid.set_new_window_placement | placement¹ (default last) | |
| Monocle | monocle.set_orientation | horizontal|vertical |
monocle.set_hide_style | stack|park (default stack; park hides unfocused windows at the stash corner) | |
monocle.set_wrap_focus | true|false (default false, matching scrolling/track) | |
monocle.set_new_window_placement | placement¹ (default first) | |
| Track | track.swap | prev|next — swap the focused window’s whole track with the adjacent one |
track.set_axis | vertical|horizontal (default vertical = columns) | |
track.set_limit | n (0 = automatic; n>0 pins a cap and turns automatic off) | |
track.set_auto_tracks | true|false (default true) | |
track.set_new_window | own_track|focused_track (default focused_track) | |
track.set_new_window_position | placement¹ (default first) — where within the new_window choice | |
track.set_overflow_style | cascade_all|cascade_overflow (default cascade_all for track) | |
track.set_wrap_focus | true|false (default false) | |
| Spawn | set_new_window_placement_override | space id, placement¹ (not track spaces — they follow track.set_new_window) |
bind_profile_to_desktop files one profile per screen count on a
Desktop, for all screen setups: a second profile of another count
adds beside the first, the same count replaces it.
Screen fingerprints after the profile, as list_monitors prints
them, scope the binding to that one screen setup (Lua
reference).
¹ placement: first\|last\|before_focused\|after_focused
reset_layout_sizing clears what resize accumulated on the
active space — or the space named, or every space for all —
the session layer and the stack and track weights — so it
lands on what its profile authored (an override’s size field
stays; the global only where nothing was authored), and leaves
structure alone
(Lua reference).
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 (#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). In
stack (#67, #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.
Deleting a Space
Section titled “Deleting a Space”delete_space succeeds whether or not the removal lasts.
When the space is still declared somewhere, the response says where:
{"status": "success", "data": {"declared_in": ["profile:Work", "init.lua"]}}declared_in names every source that re-creates the space on the
next config load — what each means is under
delete_space — and is absent
when none does. The JSON goes to stdout as always; the CLI adds
one line per source on stderr, so stdout carries nothing new:
removed from the live layout but still in saved profile "Work" — save the profile to make this durablestill composed by the built-in "Developer" standard — save a profile to make this durablestill created by init.lua — remove the call that creates itApplying Ignore Rules
Section titled “Applying Ignore Rules”ignore_rules is declarative config, not a session command. Edit
ignore_rules = { "bundle.id" } in init.lua, then
apply it with:
kiwidesk reload_configFor GUI-managed setups, put the array at the root ignore_rules
key in gui.json and reload the same way. 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.
Event Stream
Section titled “Event Stream”External tools subscribe over the same socket:
kiwidesk subscribe # all eventskiwidesk subscribe space_change layout_changeEach 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 is dropped the same way and appears there
as <non-string>. 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.
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:
{"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_created—new(a genuinely new window),returned(back from another macOS Desktop, from an app that was unhidden, or from a session restore),restored(deminiaturized).window_destroyed—closed(a real close),minimized(only minimized; it will come back asrestored),hidden(its app was hidden, with ⌘H or by hiding itself as its last window closed; the window is untouched and comes back asreturned),vanished(the window is on a macOS Desktop no screen is showing; it returns asreturned). Avanishedpayload also carriesdesktop, the Mission Control number of the Desktop holding the window, where the Desktop can be read; every other reason — and a Mac without SkyLight — sendsnull.
A window re-shown after a closed destroy — an app whose close
hides the window and shows the same one again — comes back as
new and is placed like one; returned is a Desktop return, an
unhide or a session restore.
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: 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
(#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:
{"event": "layer_change", "data": {"from_layer": "default", "to_layer": "resize"}}Raw IPC Protocol
Section titled “Raw IPC Protocol”Anything that can write to a UNIX socket can drive KiwiDesk — newline-delimited JSON, one request per line:
printf '{"command":"set_mode","args":["1","grid"]}\n' \ | nc -U ~/.config/KiwiDesk/KiwiDesk.sockResponse:
{"status": "success"}{"status": "error", "error": "unknown command: ..."}Unknown commands come back with a did-you-mean suggestion when a close match exists.
© 2026 KiwiCanopy