Skip to content

Writing plugins

A plugin adds a custom script or tool to nerdr: one that rebuilds and starts the Docker setup, seeds a database or deploys a preview. Agents find it with nerdr plugin list and run it with nerdr plugin run. You find it in F5, where you can also run it directly (ui).

Each plugin is a folder with a plugin.toml in it, and whatever the plugin runs. The folder’s name is the plugin’s name: lower case letters, digits, ., _ and -.

Plugins go in ~/.config/nerdr/plugins/NAME/, beside nerdr’s config file. They are the same in every repository and every tab, whatever branch it is on.

title = "Docker dev"
description = """
Rebuilds the services chosen and restarts the local docker stack.
Run it when the user asks to start, restart or rebuild the dev setup.
"""
run = ["./run.sh"] # what an agent runs
ui = ["./ui.sh"] # the screen you use in F5
show = ["values", "output"] # what F5 shows of an agent's run
open = true # F5 opens on it when an agent runs it
confirm = false # you say yes to each agent's run
watch = false # run goes on, and what it prints goes to the agent
[[params]]
name = "repos"
type = "multi"
description = "The repositories to build."
options = ["api", "web", "worker"]
required = true
[[params]]
name = "clean"
type = "bool"
description = "Build without the cache."
  • description is required. Agents decide from it when to run the plugin, so say what it does and when it is wanted. F5 shows it too.
  • run is the command agents run, and ui is the program you use in F5. A plugin must define at least one of them. Relative paths such as ./run.sh or bin/run.sh resolve inside the plugin’s folder; bare command names without a path are looked up on PATH. Put the plugin’s scripts inside its folder and reference them as ./name (see Safety for path and interpreter restrictions).
  • plugin.toml must be a regular file of at most 1 MB (description at most 8 KB, and each command argument at most 4 KB).
  • title is optional, a name for people.
  • show is optional: what F5 shows of an agent’s run, beyond that it runs (see When an agent runs it).
  • open is optional, and true unless set to false: whether F5 opens on the plugin when an agent runs it. When it is off, only the plugin’s light in the list shows that it runs.
  • confirm is optional: true asks you before each agent’s run (see Saying yes to each run).
  • watch is optional: true for a run that goes on until it is stopped, and tells the agent what it finds (see A plugin that watches).

Each [[params]] is one thing the plugin takes:

Key Meaning
name Its name: --repos on the command line, NERDR_PARAM_REPOS in the environment
type string (the default), bool, choice (one of options) or multi (a list, of options if it has them)
description What it is, for the agent and for you
options What it can be
required An agent must give it, or ask you for it; not with a default
default Its value when it is not given

An agent gives them as nerdr plugin run docker-dev --repos api,web --clean, or all at once with --json '{"repos":["api"]}'. If a required parameter is missing, nerdr refuses the run and tells the agent to ask you. A value is at most 64 KB; a list at most 256 values, each at most 4 KB.

When an agent runs nerdr plugin run, nerdr runs the plugin for it, once you have allowed it (see Safety). nerdr runs it as you, outside any sandbox the agent is in. So a plugin can do for an agent in a sandbox what the sandbox stops it from doing: restart the docker setup, or read a log elsewhere.

The plugin runs in the folder the agent ran the command in. That folder must be the tab’s folder, one of its worktrees, or a folder below them; a request for anywhere else is refused. nerdr keeps the checked folder open until the plugin starts, so swapping in a symbolic link while a run waits for your yes cannot send it elsewhere.

What the plugin prints, standard output and standard error together, goes back to the agent’s command with its exit status. If that command is interrupted or stops reading, for example when it times out, nerdr stops the run.

It gets:

  • each parameter as NERDR_PARAM_NAME: a list joined with commas, a bool as true or false;
  • all of them as one JSON object on standard input;
  • NERDR_PLUGIN, its name, and NERDR_PLUGIN_DIR, its folder;
  • the tab’s variables, as its screen does (see A screen in F5), but not the agent’s own environment.

The parameters come from the agent. nerdr passes on only those the plugin declares, of the right type, and one of its options when it has them. A string, or a multi without options, is any text the agent likes. Use it with care: quote it ("$NERDR_PARAM_NOTE"), never run it as code (eval, sh -c "$…"), and never let it name a file or command to run unless you check it. When you press a in F5 to review a plugin, nerdr highlights which parameters accept free-form text.

You can also run a plugin yourself with the same command, in a tab’s shell or outside nerdr. It then runs right there, in the foreground.

When an agent runs a plugin, F5 opens beside the agent on that plugin, without taking the keyboard, unless the plugin’s open is false. The plugin’s light in the list is on while it runs, with who started it and for how long. When the run ends, the light shows the result: green for exit status 0, red with the status for anything else. If you are using another of the panel’s screens, nerdr leaves it alone and tells you about the run instead, as it does for a run in another tab.

show sets what else F5 shows:

show What F5 shows of the run
values The parameters the agent gave it
output What it prints, as it prints it: what the agent gets back
ui Its screen, which nerdr starts for the run if it is not running (the plugin needs a ui). This starts the screen of a plugin you allowed without you pressing enter

A run in a terminal pane (nerdr terminal nerdr plugin run …), or one you start yourself in a shell, is not shown: you see it where it runs.

While a plugin’s screen runs, nerdr tells it about the agents’ runs in the file named by NERDR_PLUGIN_EVENTS, one JSON object per line. The screen can then show the run its own way: tick the boxes of the values given, show its button pressed, list what comes back. It reads the file as it grows, for example with tail -f "$NERDR_PLUGIN_EVENTS":

{"event":"start","run":"3f9c0a1b2c3d4e5f","agent":"1:claude","values":{"repos":["api","web"],"clean":true},"time":"2026-10-24T14:02:11+02:00"}
{"event":"output","run":"3f9c0a1b2c3d4e5f","text":"building api\n"}
{"event":"end","run":"3f9c0a1b2c3d4e5f","exit":0}

output is what the run printed since the last output, as the agent gets it, without control characters. A screen started during a run first gets its start and what it has printed so far. The screen only shows the run: run does the work, and the agent gets what run prints.

Once you allow a plugin, agents can run it whenever its description fits what they are doing. Agents in a sandbox are the exception: nerdr always asks you first, since the plugin runs outside the sandbox.

To be asked every time for one plugin, such as a deploy, set confirm = true in its plugin.toml. To be asked for every plugin, set confirm_plugin_runs = true in nerdr’s config (settings, Plugins).

nerdr then asks you before the run, and shows the tab, the agent and the parameters it gives. y runs it. n does not, and the agent is told not to try again unless you ask. The agent waits about a minute and a half; if you have not answered by then, the run does not happen. If you are answering another question, the agent is told to try again shortly.

A plugin’s confirm can only add questions: with confirm_plugin_runs = true, every plugin asks, whatever its confirm says. The question is a check with you, not a sandbox. Allow only plugins you have read.

Some jobs go on: watch a log for errors, a queue for failed jobs, a build for warnings. An agent’s command cannot wait for them, since agents cut a command off after a minute or two. With watch = true:

  • nerdr plugin run starts it, as any run, and returns at once.
  • What it prints goes to that agent as a message, typed in when the agent is idle. What it prints while the agent is busy is added to the waiting message.
  • So print only what the agent should act on, one line at a time as it appears (grep --line-buffered, tail -F), and drop repeated lines: a watch that prints the same error every minute sends the agent that error every minute.
  • It keeps running until the agent runs nerdr plugin stop NAME, you press s on it in F5, the agent’s pane closes, or nerdr quits. Stopping a watch sends SIGTERM to its process group, and SIGKILL five seconds later. nerdr tells the agent when a watch stops, unless the agent stopped it.
  • An agent watching is not put to sleep (sleep_after).

For example, logwatch/plugin.toml:

description = """
Watches the dev server's log and prints each new error, once.
Start it when the user wants errors in the log looked at as they come.
"""
run = ["./run.sh"]
watch = true
show = ["output"]

and logwatch/run.sh:

#!/bin/sh
tail -F -n 0 "$HOME/dev/server.log" | grep --line-buffered -E 'ERROR|panic' | awk '!seen[$0]++ { print; fflush() }'

Tell the agent what to do with what it finds, for example: “Start logwatch. For each error it reports, make a tab that fixes it and opens a pull request; don’t ask me about each tab.” The agent hands each error to a new tab, whose agent fixes it and opens the pull request, and goes on listening.

F5 lists the plugins, each with its description and parameters. a shows what is in the selected plugin’s folder and y allows it. d takes the allowance back for every version of it in the folder, so later runs and screens wait for a new yes; a screen or watch already running goes on. For a plugin with ui, enter starts its program below the list, in the tab’s folder, and gives it the keyboard. It keeps running when you switch screens. Use any terminal UI: a shell script with read, gum, fzf, bubbletea, textual.

While the screen has the keyboard, every key goes to it except F1-F5 and nerdr’s prefix. F5 goes back to the list, esc in the list to the agent, and x in the list stops the program. When it ends, its last screen stays until enter starts it again.

The screen gets the tab’s variables (NERDR_WORKTREES, a JSON list of the tab’s worktrees, among them), NERDR_PLUGIN and NERDR_PLUGIN_DIR. To do what an agent would, it runs its own run, or "$NERDR_BIN" plugin run NAME ….

A plugin runs as you, outside every agent’s sandbox. Its ui screen runs when you start it, and its run command when an agent asks for it with parameters; for an agent in a sandbox, only once you say yes. That is what plugins are for: letting agents start actions you have approved. But an agent outside a sandbox, or one inside with write access to ~/.config/nerdr, could create or change plugins. So no plugin starts or runs until you allow it:

  • F5 marks a plugin “not allowed” until you do. a shows what is in its folder, each file and its text, and y allows it. enter on a screen not allowed yet asks the same, then starts it. d stops allowing every version of it in the folder again; a screen or watch already running goes on until you stop it.

  • What you allow is the folder’s contents, not its name: the same files in another folder are allowed too, and renaming the folder does not ask again. Keep what you allow inside its folder.

  • nerdr plugin run refuses a plugin not allowed, and nerdr tells you which agent wanted to run it. An agent that makes a plugin cannot have it run before you have read it.

  • What you allow is everything in the folder as it was shown, an empty folder included. Any change to the folder, while you read it or later, requires approval again. plugin.toml is read only when it is a regular file (symlinks, FIFOs and devices are refused), and any file in the folder that a command references must exist and not be a symlink pointing outside the folder.

  • Keep what a plugin runs inside its folder. A script outside it is not covered by your approval, and could be changed without you seeing it, for example in the worktree where the agent runs. Relative command paths are resolved from the plugin’s folder, and paths that lead outside it are refused. An absolute path to a tool, such as /bin/sh, is used as it is and shown when you approve, but it is not part of the folder’s hash, so use only tools you trust.

    When a command runs a known interpreter or runner (sh, bash, python, node, perl, ruby, php, lua, …, versioned names such as python3.12 and node20, npx/bunx, or env with its options and NAME=value words), the script it is given must also be inside the plugin’s folder. A data file may be elsewhere, since nerdr neither reads nor hashes it.

    This check catches the common cases, not all of them. Build tools (make, npm, docker), wrappers (timeout, xargs), module lookups (npx, python -m) and script names built at run time read files from the folder the agent runs in. Instead of calling those directly in plugin.toml, put a wrapper script in the plugin’s folder and run it as ./name.

  • nerdr hashes the folder just before it starts the program, in the process that then starts it. A change after that, or one made while the script runs, is not seen. A link in the folder is approved as a link: what it points to is not hashed, and a script that reads it later can read contents outside what you allowed. Keep what the script reads inside the folder.

  • An agent can have at most eight plugin runs going at once, and nerdr thirty-two in all. More wait until one ends.

  • A sandboxed agent must be able to read ~/.config/nerdr/plugins for nerdr plugin list and nerdr plugin run: it reads the plugin’s description, parameters and key there. The allowance itself is checked by nerdr through the socket.

  • Allow a plugin for what it does with any parameters an agent may give: you read the code, not the values it will get. For a plugin you want to decide on each time, set confirm = true.

Approvals are kept in ~/.local/state/nerdr/plugins-allowed.json. An agent in a sandbox must not be able to write it, nor ~/.config/nerdr/plugins; see Agents in a sandbox.