# 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.

You do not need to publish anything to start

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`.

Writing a plugin is its own permission

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](/docs/tui-plugins).

text 

1

2

3

4

5

```
~/.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.

Terminal

bash 

```
npm install --save-dev @nikcli-ai/plugin
```

$ shell · paste into your terminal

.nikcli/plugins/hello.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

```
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.

nikcli.json

json 

1

2

3

4

```
{
  "$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.

Global instead of per project

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.

Terminal

bash 

```
nikcli --print-logs
```

$ shell · paste into your terminal

If the plugin registers a tool, the fastest confirmation is to ask the server what tools exist for the instance:

Terminal

bash 

```
curl -s "http://127.0.0.1:4096/experimental/tool/ids?directory=$PWD"
```

$ shell · paste into your terminal

## 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.

ts 

1

2

3

4

```
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.

ts 

1

2

3

4

5

6

```
import type { PluginModule } from "@nikcli-ai/plugin"

export default {
  id: "@scope/my-plugin",
  server: async (input, options) => ({ /* hooks */ }),
} satisfies PluginModule
```

server and tui are mutually exclusive

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](/docs/build/client) 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   1  2  3  4  5  ``` {   "plugin": [     ["./.nikcli/plugins/notify.ts", { "channel": "#builds", "quiet": true }]   ] } ```  ts   1  2  3  4  5  6  7  8  9  ``` 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 | | --- | --- | --- | | `tool` | At load | Register tools by name (see below). | | `event` | Any server event | Nothing — this one observes. | | `config` | Config is resolved | The config object. | | `auth` | At load | Register an auth method for a provider. | | `provider` | At load | Register a provider and its model list. | | `chat.message` | A user message arrives | Nothing — observes the message and parts. | | `chat.params` | Before the model call | `temperature`, `topP`, `topK`, provider options. | | `chat.headers` | Before the model call | Outbound headers. | | `chat.request` | Immediately before dispatch | The serialized `url`, `headers` and `body`. | | `permission.ask` | A permission is requested | `status`: `ask`, `allow` or `deny`. | | `shell.env` | A PTY or shell is created | Environment variables merged over the caller's. | | `shell.create.before` | Before a shell invocation | `command`, `cwd`, `timeout`, `shell`, `env`. | | `command.execute.before` | Before a slash command | The message `parts`. | | `tool.execute.before` | Before a tool runs | The tool `args`. | | `tool.execute.after` | After a tool runs | `title`, `output`, `metadata`. | | `experimental.chat.system.transform` | Before the model call | The system prompt blocks. | | `experimental.chat.messages.transform` | Before the model call | The whole message list. | | `experimental.session.compacting` | Before compaction | Extra `context`, or replace the `prompt`. | | `experimental.text.complete` | A text part completes | The final `text`. | | `dispose` | Instance 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   1  2  3  4  5  6  7  8  9  10  11  12  13  14  15  16  17  18  19  20  21  22  23  24  25  26  27  28  29  ``` 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`, `agent` | Identity of the call. | | `abort` | An `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   1  2  3  4  5  6  7  8  ``` {   "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 ```  $ shell · paste into your terminal  See [the plugin manifest](/docs/plugins#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](/docs/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()`. |  ## Related  -   [Plugin recipes Eight complete plugins to copy from.](/docs/guides/plugin-recipes) -   [Plugins & Skills Installing, configuring and the plugin manifest.](/docs/plugins) -   [TUI Feature Plugins The other plugin runtime — routes, panels and keybinds.](/docs/tui-plugins) -   [Tools The built-in tools your own sits beside.](/docs/tools)     `` ````

---

Source: https://nikcli-ai.dev/docs/guides/plugins
