The plugin API
Every namespace on the neosh object, with what it is for. The generated types beside your config are the authoritative signatures; this page is the map.
neosh init writes the full typed API to ~/.config/neosh/types/, generated by the binary you are running, and machine-readable copies live at /llms-full.txt. This page walks the surface.
Buffers and windows
buf creates and edits text buffers; win places them and moves cursors.
const buf = await neosh.buf.create({ name: "[tasks]", scratch: true, kind: "acme.tasks" });
await neosh.buf.setLines(buf, 0, -1, ["one", "two"]);
await neosh.buf.appendText(buf, "streamed text");
neosh.buf.onChange(buf, () => { /* redraw */ });
const win = await neosh.win.open(buf, "right", { size: 40 });
await neosh.win.setCursor(win, 3, 0);
await neosh.win.scrollTo(win, null); // null follows the tail; 0 is the top
const open = await neosh.win.ofKind("neosh.sidebar");
await neosh.win.resize(win, 44); // in place, keeping focus
A buffer’s kind is its public identity: what keymaps scope against, what win.ofKind finds, and what activation events fire on. Publish one for anything more than a scratch buffer.
float is the same for floating windows: float.open(buf, opts) with anchors, borders, titles, modal (takes global keys out of the chain) and closeOnBlur. focus is the focus stack: push, pop, current, onChange.
Columns are UTF-8 byte offsets
Every column in the API is a byte offset, matching Neovim, so display-width maths stays in the frontend. JavaScript’s String.length counts UTF-16 units and is wrong for anything a model produces.
import { width, clipToWidth, padToWidth, byteLength, byteOffsets } from "@neosh/api";
width("日本") // 4 columns, 2 code points
padToWidth(name, 20) + "│" // a rule that lines up
await neosh.ns.mark(ns, buf, row, 0, { hlGroup: "Title", endCol: byteLength(line) });
These are synchronous and use the same measurement the renderer does, so your layout and the frame agree by construction.
Marks and highlights
ns is extmarks: create a namespace, put marks with highlight groups, virtual text and line highlights on rows, clear them. Redrawing a row means clearing its marks first; a mark clamps rather than dies when its line is replaced.
hl defines and reads highlight groups. Groups are semantic and owned; link to the theme’s rather than picking colours, and pass default: true to define only if nobody has:
await neosh.hl.define("Acme.Due", { link: "Status.Unread" }, { default: true });
await neosh.hl.define("MyPlugin.Working", { link: "Status.Streaming" }); // sweeps, no timer to own
const { resolved } = await neosh.hl.get("Normal");
Commands and keys
await neosh.cmd.register("acme.count", () => ({ n: 3 }), { desc: "Count things" });
await neosh.cmd.exec("acme.count"); // the key press: fire and forget
const { n } = await neosh.cmd.call<{ n: number }>("acme.count"); // the question: returns the handler's answer
await neosh.keymap.set("chat", "<C-h>", "acme.count", { desc: "Count" });
A command handler receives (args, key, here): the arguments, the KeyContext of the press if there was one, and the whole API bound to the terminal the key was pressed in. keymap.capture(win, command) routes keys nothing claimed to a command while a window is focused, for widgets that need raw input.
The agent
agent is the conversation surface: send and cancel, drive any conversation by id, read and set the model, watch everything.
await neosh.agent.send("run the tests");
const id = await neosh.agent.command({ command: "new_session", title: "the tests" });
await neosh.agent.command({ command: "send", text: "run the suite" }, id!);
await neosh.agent.command({ command: "interrupt" }, id!);
neosh.agent.onToken((e) => { /* e.session, e.turn, e.text */ });
neosh.agent.onToolStart((e) => { /* e.call */ });
neosh.agent.onTurnEnd((e) => { /* join on endings */ });
onTurnEnd plus agent.command is an orchestrator: fan work out over several conversations and the screen never moves. Also here: the composer (setDraft, onComposerChange), attachments (attach, detach), model selection (selection, setSelection, listModels, onSelectionChange), and credentials (credentials, setCredential, which opens the host’s own prompt and never returns a key).
Tools, hooks and providers
tool.register gives the model a tool, gated by the permission layer like every tool (needs tools). hook.register observes or, with hooks_blocking and { blocking: true }, rewrites and vetoes; turn.route is the hook that decides which model answers a turn. provider.register adds a model provider driver (needs providers).
await neosh.hook.register("turn_route", (p) => {
if (p.hook !== "turn_route" || !p.text.startsWith("quick:")) return { action: "continue" };
return { action: "modify", payload: { ...p, selection: { instance: "local", model: "small", options: [] } } };
}, { blocking: true });
Git and generation
const status = await neosh.git.status(); // rejects with not_found outside a repository
await neosh.git.diff({ kind: "staged" }, { stat: true });
await neosh.git.commit("fix: the thing"); // needs vcs_write; reads need nothing
const { branch } = await neosh.gen.json<{ branch: string }>(
"Return JSON with one key: branch.\n\nUser message:\n" + text,
);
gen runs one-shot generation outside any conversation: nothing enters session history, so asking for a commit message does not change what the agent believes it was asked to do. gen.json tolerates what models actually return, fences and preambles included. Make your prompts options rather than constants, in the two layers the git plugin uses: *.instructions appended, *.prompt replacing.
Sessions and views
await neosh.session.list(); // what the user works in
await neosh.session.list({ includeArchived: true });
await neosh.session.archive(id); // reversible; archive is the verb for "done with it"
await neosh.session.close(id); // deletes the file; gate behind confirmDestructive
neosh.session.onChange((e) => { /* which terminal moved, and to what */ });
view is the terminals: list, current, onOpen, onClose, and view.at(id), which is the whole API bound to one terminal, so a window opened through it lands there. Most plugins never need it; windows opened in answer to a key are routed for you. The exception is a dock, which exists once per terminal, so open one per view in view.onOpen.
Editing text
Your plugin’s text field behaves like the composer by asking the same questions:
await neosh.edit.move(win, "word_left", { select: true }); // shift-and-arrow, in one call
await neosh.edit.apply(win, { kind: "insert", text: "hi" });
await neosh.edit.selectShape(win, "inclusive"); // a normal mode; "line" for V
await neosh.edit.cursorShape(win, "block"); // give it back when your mode ends
await neosh.edit.copy(await neosh.edit.selection(win)); // OSC 52: reaches the machine you sit at
Motions are verbs the core resolves rather than positions you compute, because grapheme and word boundaries are genuinely hard and nobody should get them right twice.
Remembering things
Three stores, by who has a reason to look:
// state: yours alone, keyed by plugin id, survives restarts. Arrangement, not configuration.
await neosh.state.set("favorites", ["/home/me/project"]);
// vars: shared, scoped to the workspace, a project, a conversation, a buffer or a window.
import { projectScope } from "@neosh/api";
await neosh.vars.set(projectScope(cwd), "sidebar.favorite", true);
neosh.vars.onChange((e) => { /* whoever changed it, including you */ });
// opt: settings the user wrote. Declare yours and config.toml can set it.
await neosh.opt.declare({ name: "acme.enabled", type: { type: "bool" }, default: true, description: "…" });
Default to state; reach for vars when somebody else genuinely needs the value. A var write is a file write for workspace, project and session scopes; buffer and window vars are in memory and die with their buffer or window. Nothing secret goes in any of them.
Events
await neosh.event.emit("acme.indexed", { files: 412 });
neosh.event.on("acme.indexed", (e) => { /* e.data, and e.from stamped by the host */ });
Broadcast, with no reply by construction. When you need an answer, register a command. The workspace’s own events arrive the same way, from: "neosh":
| Event | Data |
|---|---|
neosh.ready | Once every plugin has loaded, and again after a reload |
neosh.win.enter, neosh.win.leave, neosh.win.open | { win, buf, kind } |
neosh.win.close | { win } |
neosh.cursor | { win, row, col } |
neosh.mode | { mode } |
neosh.viewport | { win, width, height } |
event.on(name, cb, { kind: "neosh.sidebar" }) keeps only the events about one kind.
Talking to the person
Three calls, split by whether the user asked:
neosh.notify("copied /home/me/proj"); // a reply to a key; does not stack; never leaves the terminal
neosh.progress("acme.index", "indexing…"); // a state, keyed and replaced in place
neosh.done("acme.index"); // put this in a finally
await neosh.alert("acme", "index is stale", { session }); // news; may leave the terminal; needs `notify`
await neosh.ask([{ /* UserQuestion */ }]); // a real question, same panel the agent's questions use
Whether an alert leaves the terminal is the host’s decision, never yours: only it knows who is looking at what. ask resolves to null when nobody answered, which is an answer, not an error.
Everything else
| Namespace | What it is |
|---|---|
ext | Contribution points: contribute, list, onChange, points(), plugins(). See Panels |
status, hint | The status strip and the shortcut row. Keyed entries with priorities |
quota | Usage gauges: list, refresh, history, and report() for a provider you wrote |
swarm | The other machines: nodes, agents, command, pair, probe, onStream |
permission | The agent’s permission mode. setMode lasts for the session only |
rtp | add another plugin directory, from init.ts only, before discovery |
path | complete(query) for directory completion in your own prompts |
timer | every, debounce; cancelled with your plugin, unlike the globals |
log | Levelled logging that ends up in the host’s log, never on screen |
ui | Raw cell surfaces, for the one plugin in a hundred that needs to paint cells (needs raw_cells) |
And @neosh/api/ui ships the widgets everything above is built from: picker, railPicker, prompt, confirm, confirmDestructive, and ListPanel. They are ordinary API consumers; vendor your own copy and it behaves identically. Panels and extension points covers them.