Skip to content
nikcli
Docs
Documentation

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 ActionAPI
SetupOne workflow fileStart a server, write a script
Triggered byA mention in an issue or PR commentAnything you can script
Posts back to GitHubBuilt inYou do it
Works onGitHub ActionsAny CI
Control over the flowThe action's inputsTotal

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

InputRequiredWhat it does
modelyesThe model to run, as provider/model.
agentnoA primary agent. Falls back to the configured default, then build.
promptnoOverrides the default prompt — this is how a scheduled job says what to do.
mentionsnoComma-separated trigger phrases, case-insensitive. Defaults to /nikcli,/nik.
sharenoShare the session. Defaults to true on public repositories.
variantnoProvider-specific reasoning effort, such as high or minimal.
use_github_tokennoUse GITHUB_TOKEN directly and skip OIDC exchange. Defaults to false.
oidc_base_urlnoOnly 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
Needsid-token: write and the app installedOnly GITHUB_TOKEN
Comments appear asThe nikcli appgithub-actions[bot]
Good forNormal useForks, 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
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
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
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.

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

SymptomCause
Job hangs, no outputA permission request nobody answered.
##[error]Transport aloneThe nikcli child process died; the real error is earlier in the log.
Connection refusedStarted the script before the server was healthy, or it fell back to another port.
Action never triggersThe if: guard does not match the mention, or mentions was changed.
OIDC exchange failsMissing permissions: id-token: write, or the app is not installed.
Agent edits files in a read-only jobApproving every permission instead of filtering by request.permission.
Gate never failsMatching prose instead of a sentinel line.