# 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](/docs/guides/missions) 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 

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

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

| Field | Required | What it does |
| --- | --- | --- |
| `name` | yes | Identifies the stage in run history. |
| `agent` | yes | Which agent runs it — and therefore which tools and permissions apply. |
| `objective` | yes | The prompt. Write it as a contract, including what the stage must _not_ do. |
| `model` | no | Overrides the model for this stage only. |
| `tokenBudget` | no | Caps 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 

1

2

3

4

5

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

// every six hours
trigger: { kind: "interval", everyMs: 6 * 60 * 60 * 1000 }
```

| Field | Effect |
| --- | --- |
| `maxRuns` | Stops the loop after this many runs — the safety rail on a new interval loop. |
| `timeoutMs` | Caps a single run's wall-clock time. |
| `createPR` | Opens a pull request from the loop's branch when a run produces changes. |
| `sandbox` | Runs 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 

1

2

3

4

5

6

7

8

9

10

11

12

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

1

2

3

4

5

6

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

1

2

3

4

5

6

7

8

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

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

| Symptom | Cause |
| --- | --- |
| Stage stops halfway through | `tokenBudget` exhausted. Raise it, or split the stage. |
| Later stages have nothing to do | An earlier stage did their work. Add the negative half to its objective. |
| Your working tree changed under you | No `worktree` set, so the loop ran in the project directory. |
| Keeps running after you stopped it | `abort` stopped the run; the interval still fires. Also `pause`. |
| No pull request | `createPR` unset, or the run produced no changes. |
| Runs cost more than expected | No `maxRuns` on an interval trigger. |

## Related

-   [Loops The loop subsystem itself, beyond the API.](/docs/loops)
-   [Orchestrating missions The other shape: a dependency graph that runs once.](/docs/guides/missions)
-   [Loops API Every loop endpoint and payload.](/docs/api/loop)
-   [Using the API The client this guide assumes.](/docs/guides/api)

---

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