# Running nikcli in CI

Mention it on a pull request with the GitHub Action, or drive a headless server from any CI with the API.

## Two ways to run in CI

|  | GitHub Action | API |
| --- | --- | --- |
| Setup | One workflow file | Start a server, write a script |
| Triggered by | A mention in an issue or PR comment | Anything you can script |
| Posts back to GitHub | Built in | You do it |
| Works on | GitHub Actions | Any CI |
| Control over the flow | The action's inputs | Total |

Start with the action. Move to the API when you need a shape the action does not have — a nightly job that opens its own PR, a gate that fails the build, a run on GitLab.

## The GitHub Action

The action runs nikcli inside a workflow and answers when someone mentions it in a comment. The default triggers are `/nikcli` and `/nik`.

.github/workflows/nikcli.yml

yaml 

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

```
name: nikcli

on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]

jobs:
  nikcli:
    if: |
      contains(github.event.comment.body, '/nik') ||
      contains(github.event.comment.body, '/nikcli')
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          persist-credentials: false

      - name: Run nikcli
        uses: nikcli/nikcli/github@latest
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          model: anthropic/claude-sonnet-4-20250514
          use_github_token: true
```

Provider keys go in repository or organisation secrets — **Settings → Secrets and variables → Actions**. The `if:` guard matters: without it the job starts on every comment and exits, which is slow and noisy.

## Action inputs

| Input | Required | What it does |
| --- | --- | --- |
| `model` | yes | The model to run, as `provider/model`. |
| `agent` | no | A primary agent. Falls back to the configured default, then `build`. |
| `prompt` | no | Overrides the default prompt — this is how a scheduled job says what to do. |
| `mentions` | no | Comma-separated trigger phrases, case-insensitive. Defaults to `/nikcli,/nik`. |
| `share` | no | Share the session. Defaults to true on public repositories. |
| `variant` | no | Provider-specific reasoning effort, such as `high` or `minimal`. |
| `use_github_token` | no | Use `GITHUB_TOKEN` directly and skip OIDC exchange. Defaults to `false`. |
| `oidc_base_url` | no | Only for a custom GitHub App install. Defaults to `https://api.nikcli-ai.dev`. |

share defaults to true on public repos

A shared session gets a public link. That is usually the point on an open-source repository and usually not what you want on a private one that has been made public — set `share: false` explicitly if you are unsure.

## Token exchange vs GITHUB\_TOKEN

By default the action exchanges a GitHub OIDC token for an app token, which is why the job needs `permissions: id-token: write`. Setting `use_github_token: true` skips that and uses the workflow's own `GITHUB_TOKEN`.

|  | OIDC exchange (default) | `use_github_token: true` |
| --- | --- | --- |
| Needs | `id-token: write` and the app installed | Only `GITHUB_TOKEN` |
| Comments appear as | The nikcli app | `github-actions[bot]` |
| Good for | Normal use | Forks, self-hosted, no app install |

A bare Transport error means the child died

If a run fails with nothing but `##[error]Transport`, the nikcli server the action started exited — the transport error is the symptom, not the cause. Look above it in the log for the child's own output.

## Running it on a schedule

The action does not have to be triggered by a comment. Give it a `prompt` and a `schedule` trigger and it becomes an unattended job.

.github/workflows/nightly.yml

yaml 

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

```
name: nightly-audit

on:
  schedule:
    - cron: "0 3 * * *"
  workflow_dispatch:

jobs:
  audit:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false

      - uses: nikcli/nikcli/github@latest
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          model: anthropic/claude-sonnet-4-20250514
          agent: plan
          share: "false"
          prompt: |
            Audit the repository for dependencies more than two majors behind.
            Report findings only. Do not modify any files.
```

Say what it must not do

An unattended run has nobody to stop it. "Report findings only. Do not modify any files" is doing as much work in that prompt as the instruction itself.

## The API path

On any other CI, start a server in the job and drive it. The server is local to the runner, so loopback needs no password.

.gitlab-ci.yml

yaml 

1

2

3

4

5

6

7

8

9

```
review:
  image: oven/bun:1
  script:
    - bun add -g nikcli-ai
    - nikcli serve --hostname 127.0.0.1 --port 4096 &
    - until curl -sf http://127.0.0.1:4096/global/health; do sleep 1; done
    - bun run ci/review.ts
  variables:
    ANTHROPIC_API_KEY: $ANTHROPIC_API_KEY
```

Wait for health, do not sleep

The server picks an ephemeral port if the requested one is taken, and starts in variable time. Poll `/global/health` rather than guessing a sleep — a fixed sleep is the single most common cause of a flaky nikcli CI job.

## Permissions in an unattended run

Nothing is watching, so a permission request stops the run forever unless something answers it. Answer deliberately rather than approving everything.

ts 

1

2

3

4

5

6

7

8

9

10

11

```
const ALLOWED = new Set(["read", "grep", "glob", "webfetch"])

const pump = setInterval(async () => {
  const { data: pending } = await client.permission.list()
  for (const request of pending ?? []) {
    await client.permission.reply({
      requestID: request.id,
      reply: ALLOWED.has(request.permission) ? "once" : "reject",
    })
  }
}, 1000)
```

A throwaway container is the one place blanket approval is defensible. A runner with deployment credentials is not — see [Permissions](/docs/permissions).

## A review job on any CI

Reviews the diff against the base branch, prints the findings, and fails the build if the agent reports something blocking.

ci/review.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

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

```
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"

const client = createNikcliClient({
  baseUrl: "http://127.0.0.1:4096",
  directory: process.cwd(),
})

const { data: session, error } = await client.session.create({ title: "ci review" })
if (!session) throw error

const READ_ONLY = new Set(["read", "grep", "glob", "list"])
const pump = setInterval(async () => {
  const { data: pending } = await client.permission.list()
  for (const request of pending ?? []) {
    await client.permission.reply({
      requestID: request.id,
      reply: READ_ONLY.has(request.permission) ? "once" : "reject",
    })
  }
}, 1000)

try {
  const base = process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ?? "main"

  const { data: reply } = await client.session.prompt({
    sessionID: session.id,
    parts: [{
      type: "text",
      text: [
        `Review the diff between HEAD and ${base}.`,
        "Report correctness bugs only — not style, not naming.",
        "You have read-only access; do not attempt to edit files.",
        "End your reply with the single line BLOCKING: yes or BLOCKING: no.",
      ].join("\n"),
    }],
  })

  const text = (reply?.parts ?? [])
    .filter((part) => part.type === "text")
    .map((part) => part.text)
    .join("\n")

  console.log(text)

  if (/^BLOCKING:\s*yes/im.test(text)) {
    console.error("\nReview found blocking issues.")
    process.exit(1)
  }
} finally {
  clearInterval(pump)
}
```

The sentinel line is load-bearing

Parsing prose for a verdict is guesswork. Asking for one exact line and matching on it is the difference between a gate that works and one that fails the build on the word "blocking" appearing in a sentence.

## Failure modes

| Symptom | Cause |
| --- | --- |
| Job hangs, no output | A permission request nobody answered. |
| `##[error]Transport` alone | The nikcli child process died; the real error is earlier in the log. |
| Connection refused | Started the script before the server was healthy, or it fell back to another port. |
| Action never triggers | The `if:` guard does not match the mention, or `mentions` was changed. |
| OIDC exchange fails | Missing `permissions: id-token: write`, or the app is not installed. |
| Agent edits files in a read-only job | Approving every permission instead of filtering by `request.permission`. |
| Gate never fails | Matching prose instead of a sentinel line. |

## Related

-   [Using the API The client and endpoints this guide drives.](/docs/guides/api)
-   [Permissions What you are approving or rejecting.](/docs/permissions)
-   [Automating with loops Scheduled work that lives in nikcli rather than in CI.](/docs/guides/loops)
-   [Sessions API Every session endpoint.](/docs/api/session)

---

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