Plugin recipes
Eight complete plugins that each do one thing. Copy one, change the parts that are yours, point nikcli.json at the file.
How to use these
Each recipe is a whole file. Drop it in .nikcli/plugins/, add the path to
plugin in nikcli.json, and restart. If any of that is unfamiliar,
read Writing a plugin first.
{
"$schema": "https://nikcli-ai.dev/config.json",
"plugin": ["./.nikcli/plugins/deploy.ts"]
} A plugin has the access the agent has. Read a recipe before running it, the same way you would read a shell script someone handed you.
1. A tool that runs a script
The most common reason to write a plugin: give the agent a verb it cannot get wrong, instead of hoping it assembles the right shell command.
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 staging. Use this instead of running deploy scripts by hand.",
args: {
branch: tool.schema.string().describe("branch to deploy, e.g. feature/login"),
dryRun: tool.schema.boolean().optional().describe("print the plan without applying it"),
},
async execute(args, ctx) {
ctx.metadata({ title: `Deploying ${args.branch}` })
const result = await $`./script/deploy.sh ${args.branch} ${args.dryRun ? "--dry-run" : ""}`
.nothrow()
.quiet()
if (result.exitCode !== 0) {
return { title: "Deploy failed", output: result.stderr.toString() }
}
return { title: `Deployed ${args.branch}`, output: result.stdout.toString() }
},
}),
},
}) 2. A tool that streams progress
Long-running tools should report as they go. ctx.progress pushes partial results
before the tool returns, and ctx.abort lets the user cancel it.
import type { Plugin } from "@nikcli-ai/plugin"
import { tool } from "@nikcli-ai/plugin/tool"
export const Migrate: Plugin = async ({ directory }) => ({
tool: {
migrate: tool({
description: "Run pending database migrations one at a time",
args: { to: tool.schema.string().optional().describe("target migration id") },
async execute(args, ctx) {
const pending = await findPending(directory, args.to)
const applied: string[] = []
for (const [index, migration] of pending.entries()) {
if (ctx.abort.aborted) break
await ctx.progress({
structured: { done: index, total: pending.length, current: migration },
content: [{ type: "text", text: `Applying ${migration}…` }],
})
await apply(migration, { signal: ctx.abort })
applied.push(migration)
}
return {
title: `${applied.length} migration(s) applied`,
output: applied.join("\n") || "nothing to do",
metadata: { applied },
}
},
}),
},
}) 3. Auto-deny a permission class
permission.ask runs before the user is prompted. Setting
output.status decides it without them: deny refuses,
allow approves silently, and leaving it as ask falls through to the
normal prompt.
import type { Plugin } from "@nikcli-ai/plugin"
const FORBIDDEN = [/\bgit\s+push\s+--force/, /\brm\s+-rf\s+\//, /\bcurl\b.*\|\s*sh/]
export const Policy: Plugin = async () => ({
async "permission.ask"(request, output) {
const target = (request.patterns ?? []).join(" ")
if (FORBIDDEN.some((pattern) => pattern.test(target))) {
output.status = "deny"
return
}
// read-only lookups never need a prompt
if (request.permission === "webfetch") output.status = "allow"
},
}) The hook narrows what config already allows — it cannot widen past a mode that denies outright. See Permissions.
4. Inject secrets into the shell
shell.env adds environment variables to every PTY and shell the server creates,
so commands can authenticate without the value ever appearing in a transcript.
import type { Plugin } from "@nikcli-ai/plugin"
export const ShellEnv: Plugin = async () => ({
async "shell.env"(input, output) {
output.env.NPM_TOKEN = process.env.CI_NPM_TOKEN ?? ""
output.env.DATABASE_URL = `postgres://localhost/app_dev`
// only inside the infra checkout
if (input.cwd.includes("/infra")) {
output.env.AWS_PROFILE = "staging"
}
},
})
Values are merged over the caller's environment, then terminal invariants such as
TERM and NIKCLI_TERMINAL are re-applied, so you cannot break the
terminal by accident.
5. Tune model parameters
chat.params runs before every model call and knows which agent is running, so
sampling can differ per role.
import type { Plugin } from "@nikcli-ai/plugin"
export const Sampling: Plugin = async () => ({
async "chat.params"(input, output) {
// deterministic for review work, looser for brainstorming
if (input.agent === "review") {
output.temperature = 0
output.topP = 1
return
}
if (input.agent === "plan") output.temperature = 0.7
// provider-specific knobs go in options
if (input.provider.info.id === "anthropic") {
output.options.max_tokens = 8192
}
},
}) 6. Redact outbound requests
chat.request is the last hook before the HTTP request leaves the process. It sees
the serialized url, headers and body — which makes it
the right place for a redaction pass, and the wrong place for anything slow.
import type { Plugin } from "@nikcli-ai/plugin"
const PATTERNS: Array<[RegExp, string]> = [
[/sk-[A-Za-z0-9]{20,}/g, "[redacted-api-key]"],
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted-key]"],
]
export const Redact: Plugin = async () => ({
async "chat.request"(_input, output) {
if (!output.body) return
let body = output.body
for (const [pattern, replacement] of PATTERNS) body = body.replace(pattern, replacement)
output.body = body
},
}) It catches what its patterns match on the way out. Keep secrets out of the context in the first place — this is the last line, not the only one.
7. Append project rules
experimental.chat.system.transform receives the system prompt as an array of
blocks. Pushing onto it adds house rules to every turn without editing a prompt file.
import type { Plugin } from "@nikcli-ai/plugin"
import { readFile } from "node:fs/promises"
import path from "node:path"
export const HouseRules: Plugin = async ({ directory }) => {
const rules = await readFile(path.join(directory, "RULES.md"), "utf8").catch(() => "")
return {
async "experimental.chat.system.transform"(_input, output) {
if (rules) output.system.push(rules)
},
}
} Reading the file once at load rather than per turn matters: this hook is on the hot path of every model call.
8. Notify on session idle
The event hook sees every server event. session.idle is the one that
means a turn finished — useful when a long task is running in another window.
import type { Plugin } from "@nikcli-ai/plugin"
type Options = { webhook?: string }
export const SlackNotify: Plugin = async ({ client }, options) => {
const webhook = (options as Options | undefined)?.webhook ?? process.env.SLACK_WEBHOOK
if (!webhook) return {}
return {
async event({ event }) {
if (event.type !== "session.idle") return
const { data: session } = await client.session.get({
sessionID: event.properties.sessionID,
})
await fetch(webhook, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: `nikcli finished: ${session?.title ?? "session"}` }),
})
},
}
}
Every event type is listed in the Event union the SDK exports — among them
session.error, permission.asked, question.asked,
file.edited and todo.updated.
Before writing this one, check the built-in notify plugin — it covers macOS, Slack and Discord with rate limiting and a circuit breaker.