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