Skip to content
nikcli
Docs
Documentation

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.

ActionWhat it does
createWrites index.ts and package.json for a new plugin, then loads it.
updateReplaces the source; the old version's dispose() runs before the new one loads.
listEvery configured plugin with its hooks and tools, or its load error.
reloadRe-reads plugins from disk after you edited them some other way.
removeDisposes 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.

text
~/.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
.nikcli/plugins/hello.ts
ts
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
{
  "$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

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"

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

FieldTypeWhat it is
clientNikcliClientThe same typed client the Connect path uses, already bound to this instance.
directorystringThe resolved project directory.
worktreestringThe worktree root.
project{ id, worktree }Project identity.
serverUrlURLWhere this instance's server is reachable.
$Bun shellRun 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.

HookRuns whenYou can change
toolAt loadRegister tools by name (see below).
eventAny server eventNothing — this one observes.
configConfig is resolvedThe config object.
authAt loadRegister an auth method for a provider.
providerAt loadRegister a provider and its model list.
chat.messageA user message arrivesNothing — observes the message and parts.
chat.paramsBefore the model calltemperature, topP, topK, provider options.
chat.headersBefore the model callOutbound headers.
chat.requestImmediately before dispatchThe serialized url, headers and body.
permission.askA permission is requestedstatus: ask, allow or deny.
shell.envA PTY or shell is createdEnvironment variables merged over the caller's.
shell.create.beforeBefore a shell invocationcommand, cwd, timeout, shell, env.
command.execute.beforeBefore a slash commandThe message parts.
tool.execute.beforeBefore a tool runsThe tool args.
tool.execute.afterAfter a tool runstitle, output, metadata.
experimental.chat.system.transformBefore the model callThe system prompt blocks.
experimental.chat.messages.transformBefore the model callThe whole message list.
experimental.session.compactingBefore compactionExtra context, or replace the prompt.
experimental.text.completeA text part completesThe final text.
disposeInstance shutdownNothing — 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:

MemberUse
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

SymptomLikely cause
Plugin never logsThe 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 twiceTwo different function objects are exported that do the same thing; dedup is by reference, not by behaviour.
Tool missing from the modelRegistered, but gated — check permission mode and the tool gating rules in Tools.
Entry resolves outside the packageThe ./server export points outside the plugin directory; the loader refuses it on purpose.
Hook edits are ignoredYou returned a new object instead of mutating output.
A reload leaves duplicates runningThe plugin starts a timer, server or watcher and does not stop it in dispose().