Skip to content
nikcli
Docs
Documentation

Automating with loops

A loop is a staged, repeatable piece of agent work with its own budget, isolation and schedule — defined once and driven from the API.

What a loop is

A loop is a named sequence of stages, each run by an agent with an objective and a token budget. It can run on demand or on an interval, in its own worktree, optionally opening a pull request when it is done.

It is the right shape for work that repeats and has structure: a nightly QA pass, a dependency sweep, a docs audit. For one-off work, just open a session.

Loops vs missions

A loop is a linear pipeline that repeats. A mission is a dependency graph of features that runs once to completion. Reach for a mission when the work has a shape; reach for a loop when it has a cadence.

Define one

PUT /loop upserts a definition. The name is what you will recognise it by; the stages are the work.

ts
const { data: loop, error } = await client.loop.upsert({
  name: "nightly-qa",
  stages: [
    {
      name: "run-tests",
      agent: "build",
      objective: "Run the full test suite and collect every failure with its output.",
      tokenBudget: 60_000,
    },
    {
      name: "triage",
      agent: "plan",
      objective: "Group the failures by root cause. Do not fix anything yet.",
      tokenBudget: 40_000,
    },
    {
      name: "fix",
      agent: "build",
      objective: "Fix the single highest-confidence root cause. Leave the rest.",
      tokenBudget: 120_000,
    },
  ],
  trigger: { kind: "interval", everyMs: 24 * 60 * 60 * 1000 },
})
if (!loop) throw error

Stages

FieldRequiredWhat it does
nameyesIdentifies the stage in run history.
agentyesWhich agent runs it — and therefore which tools and permissions apply.
objectiveyesThe prompt. Write it as a contract, including what the stage must not do.
modelnoOverrides the model for this stage only.
tokenBudgetnoCaps the stage. A stage that runs out stops rather than spending unbounded.
Stage objectives should forbid as well as ask

Stages share a working tree in sequence. "Group the failures. Do not fix anything yet" keeps the triage stage from doing the fix stage's job and blowing its budget — the negative half of the instruction is doing real work.

Leaving model unset inherits the launching session's model, which is usually what you want: it keeps a loop consistent with the model the rest of your work runs on.

Triggers and limits

ts
// only when something asks it to
trigger: { kind: "manual" }

// every six hours
trigger: { kind: "interval", everyMs: 6 * 60 * 60 * 1000 }
FieldEffect
maxRunsStops the loop after this many runs — the safety rail on a new interval loop.
timeoutMsCaps a single run's wall-clock time.
createPROpens a pull request from the loop's branch when a run produces changes.
sandboxRuns the loop under sandbox restrictions.
Set maxRuns on the first interval loop you write

An interval loop with a bad objective does not fail loudly — it repeats. A small maxRuns turns a bad definition into three wasted runs instead of an indefinite spend, and you can raise it once you have seen the history.

Isolating the work

A loop that edits files should not do it in the tree you are working in. Give it a worktree and it gets its own directory and branch.

ts
await client.loop.upsert({
  name: "dependency-sweep",
  stages: [/* … */],
  trigger: { kind: "interval", everyMs: 7 * 24 * 60 * 60 * 1000 },
  worktree: {
    name: "dependency-sweep",
    branch: "chore/dependency-sweep",
    directory: "/Users/me/project",
  },
  createPR: true,
  maxRuns: 4,
})

With createPR set, a run that changes files ends as a pull request rather than as commits you have to find later.

Run, pause, abort

ts
await client.loop.run({ id: loop.id })            // start a run now
await client.loop.abort({ id: loop.id })          // stop the run in flight
await client.loop.pause({ id: loop.id })          // stop scheduling new runs
await client.loop.resume({ id: loop.id })         // schedule again
await client.loop.toggle({ id: loop.id })         // flip enabled state
await client.loop.delete({ id: loop.id })         // remove the definition

run accepts an optional sessionID, which attaches the run to an existing session instead of opening a new one — useful when a loop is triggered from work already in progress.

pause and abort are different questions

abort stops what is running now. pause stops the next one from starting. Stopping a misbehaving interval loop needs both.

Reading run history

ts
const { data: runs } = await client.loop.runs({ id: loop.id })
const { data: recent } = await client.loop.recentRuns({ limit: 20 })
const { data: all } = await client.loop.list()

recentRuns spans every loop, which is the view to build a dashboard on; runs is scoped to one definition.

Generating one from a description

Writing stage objectives by hand is the fiddly part. generate drafts a definition from prose, which you then review and upsert.

ts
const { data: draft } = await client.loop.generate({
  description:
    "Every morning, check for dependencies more than two majors behind, " +
    "upgrade the safest one, run the tests, and open a PR if they pass.",
})

// review before committing to it
console.log(draft?.stages.map((stage) => stage.objective))
Read the draft before you schedule it

A generated loop is a proposal. It has real token budgets and, if you set createPR, it opens real pull requests. Run it manually once before giving it an interval.

client.loop.templates() returns the built-in starting points, which is the other way to avoid writing stages from scratch.

A nightly QA loop

setup-nightly-qa.ts
ts
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"

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

const { data: loop, error } = await client.loop.upsert({
  name: "nightly-qa",
  trigger: { kind: "interval", everyMs: 24 * 60 * 60 * 1000 },
  maxRuns: 7,
  timeoutMs: 45 * 60 * 1000,
  createPR: true,
  worktree: {
    name: "nightly-qa",
    branch: "chore/nightly-qa",
    directory: process.cwd(),
  },
  stages: [
    {
      name: "run-tests",
      agent: "build",
      objective:
        "Run the full test suite. Collect every failure with its name and output. " +
        "Do not change any source files in this stage.",
      tokenBudget: 60_000,
    },
    {
      name: "triage",
      agent: "plan",
      objective:
        "Group the failures from the previous stage by root cause. Rank them by confidence. " +
        "Produce a short written plan. Do not edit code.",
      tokenBudget: 40_000,
    },
    {
      name: "fix",
      agent: "build",
      objective:
        "Fix only the highest-confidence root cause from the plan, then re-run the affected " +
        "tests. If the fix is not clearly correct, stop and explain why instead of guessing.",
      tokenBudget: 120_000,
    },
  ],
})
if (!loop) throw error

// prove it works before letting the schedule own it
await client.loop.pause({ id: loop.id })
await client.loop.run({ id: loop.id })
console.log("running once; resume the schedule when the run looks right")

Failure modes

SymptomCause
Stage stops halfway throughtokenBudget exhausted. Raise it, or split the stage.
Later stages have nothing to doAn earlier stage did their work. Add the negative half to its objective.
Your working tree changed under youNo worktree set, so the loop ran in the project directory.
Keeps running after you stopped itabort stopped the run; the interval still fires. Also pause.
No pull requestcreatePR unset, or the run produced no changes.
Runs cost more than expectedNo maxRuns on an interval trigger.