Writing a plugin
A plugin is a directory with a manifest and a TypeScript module. Drop it in, it runs, and it gets the same API every built-in feature is written on.
In a hurry? Docs for agents sets up your coding agent to write plugins for you, with one command.
Your first plugin
Create a folder under ~/.config/neosh/plugins/:
my-plugin/
├── plugin.toml
└── main.ts
name = "my-plugin"
version = "0.1.0"
entry = "main.ts"
description = "Says hello"
import type { PluginContext } from "@neosh/api";
export async function activate({ neosh, subscriptions }: PluginContext) {
const d = await neosh.cmd.register("my.hello", () => neosh.notify("hi"), {
desc: "Say hello",
});
subscriptions.push(d); // disposed when the plugin unloads
await neosh.keymap.set("chat", "<C-h>", "my.hello");
}
export async function deactivate() {} // optional: runs on unload and shutdown
Reload with ^R and press ^H. Plugins in your config directory are discovered as they are, so every save is live.
Plugins are transpiled, not type-checked, at load. neosh init writes the API types next to your config, emitted by the binary you are running, so npx tsc --noEmit checks against the exact version you have; run it, since it is the only thing that catches a type error before it becomes a runtime one.
The manifest, in full
name = "acme-tasks"
version = "0.1.0"
entry = "main.ts"
description = "A task panel"
# Declared up front, and enforced. Everything not listed here (windows, buffers,
# keys, floats, options, vars, drawing, reading) needs nothing declared: it is no
# more privileged than what the person sitting there can already do.
permissions = ["vcs_write"]
# Plugins this one builds on. Each loads and activates first, and
# `import … from "plugin:<name>"` resolves to it. A name nothing provides
# fails at startup with the name in the message.
requires = ["sidebar"]
# Soft ordering: load after these if present, without needing them.
after = ["usage"]
# What this plugin offers others: listed by `ext.points()`, and how a
# contribution to a point nobody reads gets reported instead of ignored.
[provides]
points = ["acme.tasks.section", "acme.tasks.action", "acme.tasks.decoration"]
kinds = ["acme.tasks"]
vars = ["acme.task.due"]
# Omit for a plugin that loads at startup. Present, it is held until one of
# these fires; the command is registered on its behalf and the press replayed.
[activation]
on_command = ["acme.tasks.toggle"]
on_event = ["neosh.win.enter"]
on_kind = ["neosh.sidebar"]
Permissions
| Permission | Grants |
|---|---|
tools | Register a tool the model can call |
providers | Register a model provider driver |
hooks_blocking | Take a hook that can rewrite or veto. An observer needs nothing |
raw_cells | Claim a raw cell surface |
vcs_write | Branch, checkout, stage, commit, worktree |
notify | Raise an alert outside the terminal. Drawing in the corner stays free |
These are plugin permissions, not the agent’s permission mode. Those govern what the model may do; the model reaches git and the rest through registered tools, gated like every tool.
What the runtime gives you
A bare deno_core sandbox: the ECMAScript built-ins, console, queueMicrotask, and timers. No fetch, no TextEncoder, no filesystem, no subprocess. Anything with an effect goes through the neosh API, which is what makes hooks and permissions mean something.
Imports resolve for relative paths, @neosh/api, its submodules, and plugin:<name>. There is no package resolution; bundle third-party code into your plugin.
Prefer neosh.timer over the timer globals: the globals cannot know who called them, so they are not cancelled when your plugin unloads, and an interval that outlives its plugin doubles up on every reload.
const stop = neosh.timer.every(1000, () => { /* … */ });
subscriptions.push(stop);
const redraw = neosh.timer.debounce(150, () => render());
Who wins a name
One rule for keys, commands and highlight groups: a bundled plugin offers defaults, a plugin you installed is a choice, and your own init.ts is the last word. A lower tier never takes what a higher one holds, and within a tier the later registration wins. A command name a higher tier holds is not refused to the lower one: the registration waits in the wings and comes back when the higher tier lets go.
Installing and publishing
A plugin is published by being a git repository with a plugin.toml at its root. There is no registry; a URL is already a globally unique name, and it works for a fork, a private repository and the branch you are writing.
neosh plugin add https://github.com/someone/neosh-thing
neosh plugin list
neosh plugin update # fast-forward pull, all of them
neosh plugin remove thing
add clones, validates the manifest with the same code startup uses, and only then moves it into place, so a broken plugin fails now with a sentence rather than at your next startup as a log line.
Two places, and they mean different things: ~/.config/neosh/plugins/ is yours, for plugins you are writing, live on every save and never touched by plugin remove. Installed plugins live under the data directory as checkouts neosh manages. plugin_dirs in config.toml adds any other directory.
The worked examples
The sidebar, the model switcher, git, the palette and approvals live in plugins/builtin/ in the repository. They are ordinary plugins that happen to be embedded in the binary: no private API, no exemptions, and CI type-checks them against the published @neosh/api. Read them; they are the reference for everything the API can do.
Built one? Put it in the showcase. One file, one pull request.
Next
- The plugin API walks every namespace.
- Panels and extension points is how plugins compose.
- Docs for agents if an agent is writing the plugin with you.