Writing a plugin
From an empty file to a loaded plugin with its own tool — the module shape, the runtime input, every hook, and how to ship it.
What a plugin is
A plugin is a module nikcli imports at startup. It is called once per project instance with a client bound to that instance, and it returns an object of hooks. From then on the server calls those hooks at the points they name.
Plugins run inside the nikcli process, with the same access the agent has. That is the point — a hook can change the model call, veto a permission, or add a tool — and also the caution: a plugin is trusted code.
A single local .ts file referenced from nikcli.json is a complete,
working plugin. npm is for sharing it later.
Let nikcli write it
You do not have to write the file yourself. Ask for the capability in the TUI — "create a
plugin that sends me a desktop notification when a session finishes" — and the agent uses the
built-in plugin tool: it writes the plugin into its own folder, installs its
dependencies, reloads it and reports whether it loaded, with the hooks and tools it
registered. The plugin runs from that moment on, no restart; tools it defines are callable
from the agent's next step.
| Action | What it does |
|---|---|
create | Writes index.ts and package.json for a new plugin, then loads it. |
update | Replaces the source; the old version's dispose() runs before the new one loads. |
list | Every configured plugin with its hooks and tools, or its load error. |
reload | Re-reads plugins from disk after you edited them some other way. |
remove | Disposes the plugin and deletes its folder. |
Scope global (the default) writes under ~/.config/nikcli/plugins/
and applies to every project; project writes under the project's
.nikcli/plugins/. When a plugin fails to load, the error goes back to the agent,
which fixes the source and calls update.
Plugin code runs inside nikcli with your privileges, so the plugin permission
asks by default and the prompt shows the code as a diff. The plan agent is
denied it. Set "permission": { "plugin": "allow" } only if you want the
agent to extend nikcli without asking.
Folder plugins and hot reload
Besides single files, every folder under a config directory's plugins/ is a
plugin: plugins/<name>/ with a package.json and its own
node_modules. The entry is package.json main when it
names a file inside the folder, else index.ts, else index.js. The
folder name is the plugin's name; plugins/tui/ is reserved for
TUI plugins.
~/.config/nikcli/plugins/
desktop-notify/
package.json # "main": "index.ts", dependencies
index.ts
hello.ts # single-file plugins still work
Plugins in plugins/ are discovered without a nikcli.json entry,
and they reload while nikcli runs. Editing a plugin's files, adding one or removing one
reconciles plugin by plugin: a changed plugin is disposed and loaded again, a removed one is
disposed, and every other plugin keeps running untouched. Anything long-lived a plugin starts
— timers, servers, watchers — must be released in dispose(), or a reload
leaves the old copy running beside the new one.
1. Write the file
Create .nikcli/plugins/hello.ts in your project. The only dependency is
@nikcli-ai/plugin, which carries types and the tool() helper and
nothing else at runtime.
npm install --save-dev @nikcli-ai/plugin import type { Plugin } from "@nikcli-ai/plugin"
export const HelloPlugin: Plugin = async ({ directory }) => {
console.log("[hello] loaded for", directory)
return {
async "tool.execute.before"(input) {
console.log("[hello] about to run", input.tool)
},
}
} That is a whole plugin. It exports one function; nikcli calls it and keeps the hooks it returns.
2. Point config at it
Add the path to plugin in nikcli.json. Relative paths resolve
against the config file that declares them, so this keeps working when the project moves.
{
"$schema": "https://nikcli-ai.dev/config.json",
"plugin": ["./.nikcli/plugins/hello.ts"]
}
An entry that is not a path is treated as an npm package specifier and installed on first use,
so "@scope/my-plugin@1.2.3" works with no separate install step. Paths are
imported directly and never installed.
The same key in the global config applies the plugin to every project. Use
nikcli plugin <pkg> --global to patch it, or edit the file directly.
3. Check it loaded
Start nikcli and watch the log. Loading is logged per plugin, and a plugin that throws during init is reported rather than silently skipped.
nikcli --print-logs If the plugin registers a tool, the fastest confirmation is to ask the server what tools exist for the instance:
curl -s "http://127.0.0.1:4096/experimental/tool/ids?directory=$PWD" The module shape
There are two accepted shapes, and the loader picks between them by looking at the module.
Named exports (simplest)
Every exported function is treated as a plugin and called. Exporting the same function twice — as a named and a default export — is deduplicated by reference, so it initialises once.
import type { Plugin } from "@nikcli-ai/plugin"
export const First: Plugin = async () => ({ /* hooks */ })
export const Second: Plugin = async () => ({ /* hooks */ }) Default-export manifest
A module whose default export is an object carrying id,
server or tui is read as a manifest instead. This is the shape to
use when publishing, because the id is what config and the plugin manager key on.
import type { PluginModule } from "@nikcli-ai/plugin"
export default {
id: "@scope/my-plugin",
server: async (input, options) => ({ /* hooks */ }),
} satisfies PluginModule
One module declares one runtime. A default export carrying both is rejected at load —
a TUI plugin ships from its own entrypoint (./tui in
exports), not from the server one.
What you are handed
The first argument is the runtime input for the instance the plugin was loaded into.
| Field | Type | What it is |
|---|---|---|
client | NikcliClient | The same typed client the Connect path uses, already bound to this instance. |
directory | string | The resolved project directory. |
worktree | string | The worktree root. |
project | { id, worktree } | Project identity. |
serverUrl | URL | Where this instance's server is reachable. |
$ | Bun shell | Run commands: await $`git status`.text(). |
Calling the API from a plugin is normal client is not a limited subset. Reading sessions, writing files or replying to
a permission from inside a hook is the intended way to do it, not a workaround.
Passing options
A config entry can be a [specifier, options] pair instead of a bare string. The
object arrives as the plugin function's second argument.
nikcli.json json {
"plugin": [
["./.nikcli/plugins/notify.ts", { "channel": "#builds", "quiet": true }]
]
}
ts import type { Plugin } from "@nikcli-ai/plugin"
type Options = { channel?: string; quiet?: boolean }
export const Notify: Plugin = async (input, options) => {
const { channel = "#general", quiet = false } = (options ?? {}) as Options
// ...
return {}
}
The hook surface
Every hook is optional. The mutating ones take (input, output) and you change
output in place — the value you leave there is what nikcli uses.
Hook Runs when You can change toolAt load Register tools by name (see below). eventAny server event Nothing — this one observes. configConfig is resolved The config object. authAt load Register an auth method for a provider. providerAt load Register a provider and its model list. chat.messageA user message arrives Nothing — observes the message and parts. chat.paramsBefore the model call temperature, topP, topK, provider options. chat.headersBefore the model call Outbound headers. chat.requestImmediately before dispatch The serialized url, headers and body. permission.askA permission is requested status: ask, allow or deny. shell.envA PTY or shell is created Environment variables merged over the caller's. shell.create.beforeBefore a shell invocation command, cwd, timeout, shell, env. command.execute.beforeBefore a slash command The message parts. tool.execute.beforeBefore a tool runs The tool args. tool.execute.afterAfter a tool runs title, output, metadata. experimental.chat.system.transformBefore the model call The system prompt blocks. experimental.chat.messages.transformBefore the model call The whole message list. experimental.session.compactingBefore compaction Extra context, or replace the prompt. experimental.text.completeA text part completes The final text. disposeInstance shutdown Nothing — clean up here.
A shell hook cannot smuggle past approval shell.create.before runs before the permission check, and the check is made
against the final values your hook wrote. Rewriting a command does not inherit the
approval the user granted for the original one.
Adding a tool
The tool hook is a map of name to definition. tool() is a typed
identity helper: it infers the argument type from the zod shape you declare, so
execute receives parsed, typed args.
.nikcli/plugins/deploy.ts ts import type { Plugin } from "@nikcli-ai/plugin"
import { tool } from "@nikcli-ai/plugin/tool"
export const Deploy: Plugin = async ({ $ }) => ({
tool: {
deploy: tool({
description: "Deploy a branch to the staging environment",
args: {
branch: tool.schema.string().describe("branch name to deploy"),
dryRun: tool.schema.boolean().optional().describe("print the plan without applying"),
},
async execute(args, ctx) {
ctx.metadata({ title: `Deploying ${args.branch}` })
await ctx.ask({
permission: "deploy",
patterns: [args.branch],
always: ["deploy"],
metadata: { branch: args.branch },
})
const flag = args.dryRun ? "--dry-run" : ""
const output = await $`./script/deploy.sh ${args.branch} ${flag}`.text()
return { title: `Deployed ${args.branch}`, output }
},
}),
},
})
The context argument carries the session the call belongs to and three capabilities:
Member Use sessionID, messageID, callID, agentIdentity of the call. abortAn AbortSignal — pass it to anything long-running. metadata({ title, metadata })Set the label the UI shows while the tool runs. progress({ structured, content })Stream partial results before the tool returns. ask({ permission, patterns, always, metadata })Request user approval mid-execution.
Return a plain string, or an object with output plus optional
title, metadata and file attachments.
Describe the arguments, not just the tool .describe() on each argument is what the model reads when deciding how to call
it. A tool with a good description and unlabelled arguments still gets called wrong.
Publishing to npm
Expose the server entrypoint under the ./server export condition. The installer
reads it to detect what kind of plugin the package is, and refuses an entry that resolves
outside the package directory.
package.json json {
"name": "@scope/my-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
"./server": "./dist/server.js"
}
}
Consumers then install it with one command, which also patches their config:
Terminal
bash nikcli plugin @scope/my-plugin
nikcli plugin @scope/my-plugin --global
nikcli plugin @scope/my-plugin@1.2.3 --force
See the plugin manifest for what the installer detects
and how it patches config.
Debugging
- Run with logs.
nikcli --print-logs shows each plugin as it
loads, with its resolved path.
- One plugin's failure is isolated. Hooks are dispatched in a per-hook
try/catch, so a throwing hook does not take down the others — which also means a broken hook
can look like it simply did nothing. Check the log before assuming it never ran.
- Iterate on a path, not a package. A local plugin is re-imported as soon as
its files change — no restart, no install step. npm plugins reload only when their specifier
changes.
- Confirm tools registered with
GET /experimental/tool/ids rather than by prompting and hoping.
Failure modes
Symptom Likely cause Plugin never logs The config entry is in a scope that is not loaded — project config when you edited global, or the reverse. must default export an object with server()A default export exists but carries neither server nor tui. Either complete the manifest or use plain named exports. Initialised twice Two different function objects are exported that do the same thing; dedup is by reference, not by behaviour. Tool missing from the model Registered, but gated — check permission mode and the tool gating rules in Tools. Entry resolves outside the package The ./server export points outside the plugin directory; the loader refuses it on purpose. Hook edits are ignored You returned a new object instead of mutating output. A reload leaves duplicates running The plugin starts a timer, server or watcher and does not stop it in dispose().