# 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](/docs/guides/plugins) first.

nikcli.json

json 

1

2

3

4

```
{
  "$schema": "https://nikcli-ai.dev/config.json",
  "plugin": ["./.nikcli/plugins/deploy.ts"]
}
```

These run with your credentials

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.

.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

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

.nikcli/plugins/migrate.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

30

31

32

33

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

.nikcli/plugins/policy.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

```
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"
  },
})
```

This composes with permission modes

The hook narrows what config already allows — it cannot widen past a mode that denies outright. See [Permissions](/docs/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.

.nikcli/plugins/shell-env.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

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

.nikcli/plugins/sampling.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

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

.nikcli/plugins/redact.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

```
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
  },
})
```

Redaction here is a net, not a wall

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.

.nikcli/plugins/house-rules.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

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

.nikcli/plugins/slack-notify.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

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

nikcli already ships notifications

Before writing this one, check [the built-in notify plugin](/docs/plugins#notifications) — it covers macOS, Slack and Discord with rate limiting and a circuit breaker.

## Related

-   [Writing a plugin The guide these recipes assume you have read.](/docs/guides/plugins)
-   [Permissions What permission.ask composes with.](/docs/permissions)
-   [Tools The built-in tools, and how gating decides what the model sees.](/docs/tools)
-   [API Reference Everything the plugin's client can call.](/docs/api)

---

Source: https://nikcli-ai.dev/docs/guides/plugin-recipes
