Get neosh

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

PermissionGrants
toolsRegister a tool the model can call
providersRegister a model provider driver
hooks_blockingTake a hook that can rewrite or veto. An observer needs nothing
raw_cellsClaim a raw cell surface
vcs_writeBranch, checkout, stage, commit, worktree
notifyRaise 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