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.
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.
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. |
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
// 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. |
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.
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
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.
abort stops what is running now. pause stops the next one
from starting. Stopping a misbehaving interval loop needs both.
Reading run history
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.
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))
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
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. |