Configuration
The policy file
Section titled “The policy file”The server reads ~/.config/niri-computer-use/policy.toml, or $XDG_CONFIG_HOME/niri-computer-use/policy.toml, once when it starts. Restart the agent’s session after changing it. Without the file there are no launch presets and no denied apps, and screenshots save under ~/Pictures/niri-computer-use, which is valid.
status shows what the server loaded under policy:
| Field | Value |
|---|---|
state |
loaded, missing or invalid |
presets, preset_names |
how many presets there are, and the names launch takes |
denied_app_ids |
how many apps are on the deny list |
capture_dir |
where save_path writes, as the file writes it or ~/Pictures/niri-computer-use by default, or null when saving is off |
capture_dir_source |
policy when the file sets capture_dir, "" included, default when it doesn’t or there is no file, null while the file is invalid |
error |
why the file is invalid |
status also shows unrestricted and confirm_lease, each with enabled, source (policy, env, both, or null while off) and error, which explains a NIRI_COMPUTER_USE_UNRESTRICTED or NIRI_COMPUTER_USE_CONFIRM_LEASE value other than 1. Its tools shows the tool profile: profile, source (policy, env, both, or null for full) and error, which explains a NIRI_COMPUTER_USE_TOOLS value that names no profile.
An invalid file doesn’t stop the server, but acquire_desktop and every action refuse with read_only until you fix it and restart. A file with an unknown key is invalid too.
Full example
Section titled “Full example”# Input tools refuse while one of these apps has keyboard focus.deny_input_app_ids = ["org.keepassxc.KeePassXC", "org.gnome.Settings"]
# Where screenshot's save_path writes PNG files. Without it, ~/Pictures/niri-computer-use;# "" turns saving off.capture_dir = "~/Pictures/agent-shots"
[[preset]]name = "firefox"argv = ["firefox"]app_id = "firefox"
[[preset]]name = "terminal"argv = ["foot"]app_id = "foot"
[[preset]]name = "files"argv = ["nautilus", "--new-window"]app_id = "org.gnome.Nautilus"With this file, status reports:
{"state":"loaded","presets":3,"preset_names":["firefox","terminal","files"],"denied_app_ids":2,"capture_dir":"~/Pictures/agent-shots","capture_dir_source":"policy","error":null}deny_input_app_ids
Section titled “deny_input_app_ids”A list of app_ids, default empty. While the window with keyboard focus has one of them, click, drag, scroll, pointer_move, key and type_text refuse with app_denied, even with expect: "none". elements, activate_element, set_element_text and a pointer tool aimed at an element refuse for a window of a denied app whatever has focus, and so do close_window and niri_action’s CloseWindow, for the window they name or the focused one. Focus and layout actions aren’t checked.
The check follows keyboard focus, and apps choose their own app_id, so this guards against mistakes. A click can still land on a denied window while an allowed app has focus. desktop_state shows each window’s app_id.
capture_dir
Section titled “capture_dir”Where screenshot saves files, default ~/Pictures/niri-computer-use. It is an absolute path or one starting with ~/, resolved against the HOME of the client’s session, so in shared mode each client saves under its own home. capture_dir = "" turns saving off, and screenshot then refuses save_path with save_not_enabled. While the policy file is invalid, saving is off too, since the file might have turned it off.
The directory, the default or yours, is created with mode 0700 if it is missing, along with any missing parent. save_path is relative to it, made only of plain names (no .., . or leading /), and names a .png file; subdirectories in it must already exist. The server opens subdirectories without following symlinks and creates the file new, with mode 0600, so a save can’t leave the directory or replace a file. See Screenshots.
unrestricted
Section titled “unrestricted”true or false, default false. It is off because some agents, such as desktop apps, have no shell of their own, and this server is then the only way they reach your programs. With it on, an agent can run any program through the server.
With true:
niri_actionalso sends the gated actions:SpawnandSpawnSh, which run any program,Quit, monitor power,LoadConfigFile, niri’s screenshot actions,ToggleKeyboardShortcutsInhibit,SwitchLayout, the cast actions, the debug toggles andDoScreenTransition. Without it they fail withunrestricted_required. Seeniri_action.- The
noctaliatool is listed, when Noctalia is installed, and sends any Noctalia command. - Presets may start terminals with arguments and programs that run commands, and may set
env.
NIRI_COMPUTER_USE_UNRESTRICTED=1 in the server’s environment turns it on too, so you can allow it for one MCP client only, in that client’s server settings, without a policy file. Either one turns it on, and nothing in the environment turns off a file’s true. Any other value of the variable leaves it off and shows up as status.unrestricted.error.
Turning it on never skips the lease, the stop key or the audit log: every gated action still needs the lease, stops at the stop key, and is logged, as metadata like every call.
confirm_lease
Section titled “confirm_lease”true or false, default false. With true, every acquire_desktop waits for you to allow it on the desktop before the lease is granted. The server shows a notification through notify-send, titled “Allow an agent to use this desktop?”, that names the client asking and has Deny and Allow buttons. Only Allow grants the lease. Deny, dismissing the notification, or no answer within 60 seconds refuses the call with lease_declined. A session that already holds the lease isn’t asked again.
The question comes from the desktop, not from the agent’s client, so it still applies when the client approves tool calls on its own, as in Claude Code’s bypass mode or Codex’s --yolo. The agent can’t answer it through this server either: until you allow it, it has no lease, so no tool that clicks, types or sends Noctalia commands will run. An agent that has a shell can still reach the notification server or inject input by other means, as it could with any prompt on your desktop.
The notification needs notify-send (libnotify) on the client’s PATH and a notification server that shows actions, such as Noctalia’s; status.binaries says whether notify-send was found. Without one, acquire_desktop fails with the upstream_error notify-send gave, and no lease is granted. Press the stop key, or close the client, while the notification is up and the call is refused at once. The notification then stays up until it expires, and answering it does nothing.
NIRI_COMPUTER_USE_CONFIRM_LEASE=1 in one client’s server settings turns it on for that client only. As with unrestricted, either source turns it on and nothing in the environment turns off a file’s true. Any other value of the variable, such as true, shows up as status.confirm_lease.error and, unlike unrestricted, turns the prompt on: a typo shouldn’t drop a safeguard you meant to add.
shared
Section titled “shared”true or false, default true. With true, the server serves its client through the one shared engine of its niri instance, starting the engine if none runs, instead of on its own. Every client then shares one crash guardian, one niri event stream and one accessibility connection, while its policy, unrestricted, HOME and PATH stay its own, read from its own environment as before. Its grim runs in its own working directory, as a standalone server’s would, even after that directory is renamed or removed, so relative entries on its PATH work as they do in its shell.
With false, each server serves its own client, with a guardian, event stream and accessibility connection of its own. NIRI_COMPUTER_USE_SHARED=1 or 0 in the server’s environment overrides the file for that one client. Any other non-empty value serves the client on its own, and status.engine.fallback and the server’s stderr say why.
The server decides before it reads anything from its client. If no engine answers within five seconds, the engine’s socket path in the runtime directory is over the 108 bytes a Unix socket allows, or the engine refuses it, for example because the engine runs another build of niri-computer-use or another WAYLAND_DISPLAY, it serves its client on its own and says why on stderr. Once it is relaying, it stays a relay: if the engine ends, calls in flight fail with engine_lost, the session’s next call reaches a new engine, and while none can be reached calls fail with engine_unavailable. The engine writes its errors to engine.log in the runtime directory and exits two seconds after its last connection, lease and pending input cleanup have ended. A client that stops reading its server’s output loses its server, and with it the lease: once 32 lines wait for it, or one line has waited 30 seconds, the server ends that client’s session and exits, saying why on stderr. The same happens to a client that sends more than 32 MiB the engine hasn’t taken yet.
"observe", "act" or "full", default "full". It decides which tools the server lists. A tool left out isn’t in the client’s tool list, so it costs no context, and calling it fails as an unknown tool.
| Profile | Tools |
|---|---|
observe |
status, desktop_state, screenshot, wait_for, and elements when there is an accessibility bus. None of them needs the lease, so the agent can look but can’t act |
act |
every tool but niri_action, noctalia and shell_panel |
full |
every tool, as Noctalia, unrestricted and the accessibility bus allow |
clipboard_read isn’t in observe because it needs the lease, which observe can’t take.
NIRI_COMPUTER_USE_TOOLS in the server’s environment names a profile too, so one MCP client can list fewer tools than another. The narrower of the file’s profile and the variable’s wins: either can take tools away and neither can add them back. A value other than observe, act or full lists only observe’s tools and shows up as status.tools.error.
[[preset]]
Section titled “[[preset]]”One table per app launch may start. name, argv and app_id are required:
| Key | Value |
|---|---|
name |
what the agent passes to launch; unique and not empty |
argv |
the program and its fixed arguments, started by niri as given; no shell runs it |
app_id |
the app_id its windows have, so launch can see them appear and reuse can find them |
env |
only with unrestricted: a table of variables added to the app’s environment, such as env = { GDK_SCALE = "2" }. niri starts env -- NAME=value … argv, because niri’s spawn takes no environment |
launch takes only the name; the agent can’t add arguments or variables. Unless unrestricted is on, a preset may not start:
- a shell, an interpreter,
env,sudo,doas,pkexec,systemd-run,nohup,setsid,xargs,timeout,nice,busybox,uwsm,distrobox,toolbox,tmux,screen,bwrap,firejail,unshare,chroot,ssh,flatpak-spawn,nix,nix-shell,strace,gdb,script,docker,podman, or another program that runs any command it is given; versioned names such aspython3.13count too flatpakwith--command- a terminal with arguments, not even
--app-id, because terminals run trailing arguments as a command. A terminal without arguments is fine. To give a terminal its ownapp_id, start a desktop file withgtk-launch.
A file that breaks a rule is invalid, and status names the preset:
{"state":"invalid", …, "error":"…/policy.toml: preset \"shell\" starts the terminal foot with arguments, which it can run as a command"}These rules catch common mistakes. A wrapper script or a symlink with another name gets past any list, so they are a guardrail, not a boundary. The full lists are COMMAND_RUNNERS and TERMINALS in src/policy.rs.
Environment
Section titled “Environment”The server reads these once, at startup. When XDG_RUNTIME_DIR, NIRI_SOCKET or WAYLAND_DISPLAY is unset, it finds your session itself, as described in Session variables; a set variable always wins.
| Variable | Used for |
|---|---|
NIRI_SOCKET |
niri’s IPC socket. With every symlink resolved, it also names the niri instance that the lease, the stop flag and the input-dirty marker belong to: they are in niri-computer-use/<instance>/ beside the socket, where <instance> is its file name without .sock. A socket with a hard link, which would be another name for the same niri, gets no lease by any name |
XDG_RUNTIME_DIR |
finding niri’s socket, the display and the session bus |
WAYLAND_DISPLAY |
everything that reaches the display: input, screenshots, pasting, clipboard_read, and finding Noctalia’s socket |
DBUS_SESSION_BUS_ADDRESS |
finding the accessibility bus for elements; without it, $XDG_RUNTIME_DIR/bus |
PATH |
grim and, with confirm_lease, notify-send for this client’s calls |
NIRI_COMPUTER_USE_DESKTOP_PATH |
where to look for loginctl and noctalia, as a PATH-style list of absolute directories; unset or empty looks in /usr/local/bin:/usr/bin:/bin:/run/current-system/sw/bin. Only absolute directories count, each once, and a trailing slash makes no difference. In shared mode, a client that sets other directories than the engine’s, or the same ones in another order, is served standalone |
XDG_CONFIG_HOME, else HOME |
the policy file |
XDG_STATE_HOME, else HOME |
the audit log |
HOME |
a capture_dir that starts with ~/, and the default one |
NIRI_COMPUTER_USE_UNRESTRICTED |
1 turns on unrestricted for this server; unset or empty leaves the policy file to decide |
NIRI_COMPUTER_USE_CONFIRM_LEASE |
1 turns on confirm_lease for this server; unset or empty leaves the policy file to decide |
NIRI_COMPUTER_USE_TOOLS |
observe, act or full: the tool profile for this server; unset or empty leaves the policy file to decide |
NIRI_COMPUTER_USE_SHARED |
1 turns on shared mode for this server and 0 turns it off; unset or empty leaves the policy file to decide |
The lock state comes from logind’s session for niri’s own process, which the server finds through niri’s socket, so the server’s own XDG_SESSION_ID doesn’t matter.