# Loops

Server-side, headless Loops engine: a thin orchestrator that composes Scheduler + Goal. Source: packages/nikcli/src/loop/.

## Overview

A **Loop** is a named, persisted workflow that runs an ordered list of **stages**, each of which is a single `goal` command executed by a worker agent. A **trigger** — manual or interval — decides when the loop fires.

The headless `LoopEngine` (`packages/nikcli/src/loop/engine.ts`) owns the interval timers via the core `Scheduler` with `scope: "instance"`, so scheduled loops keep firing when the TUI is closed. The TUI feature plugin (`packages/tui/src/feature-plugins/loops/`) is a thin client over the SDK: it subscribes to bus events for live status, and uses the SDK to create, run, pause, resume, and delete loops.

Primitives

### Composition over reinvention

A Loop does not reimplement iteration, budgets, or autonomy. The interval trigger is a `Scheduler.register` call; each run is a `SessionPrompt.command` invocation of `goal`. The existing `Goal` engine does the rest.

No TUI required

### Headless by design

Definitions are persisted in SQLite through `LoopRepo`; timers live in the server process. Loops fire on schedule regardless of whether a TUI is connected. A TUI that connects later syncs the live state through bus events.

Persistence

### Crash-safe

A loop and its run history are persisted after every step. Stale `running` runs are reconciled to `orphaned` on the next boot by a lease/heartbeat (`LOOP_RUN_LEASE_MS = 15s`).

Back-pressure

### Concurrency cap

A global cap of `MAX_CONCURRENT_RUNS = 3` prevents runaway workers. A per-loop single-flight claim prevents overlapping runs of the same loop.

## Concepts

| Term | Definition |
| --- | --- |
| **Loop** | Named, persisted unit: a trigger, a list of stages, optional `maxRuns` and `timeoutMs`. |
| **Stage** | One unit of work within a run: name, agent, optional model, objective, optional token budget. |
| **Trigger** | `{ kind: "manual" }` or `{ kind: "interval", everyMs }`. |
| **Run** | A single execution of the loop's stages. Carries lease/heartbeat and a session id per stage. |
| **Runtime** | Live, in-memory status of a loop (idle | running | paused | error | cancelling), updated by the engine on every bus event. |
| **Template** | A named, parameterised loop draft the user can clone and customize. |

## Loop schema

All persisted types live in `packages/nikcli/src/loop/schema.ts` as zod schemas.

### Trigger

packages/nikcli/src/loop/schema.ts

ts 

1

2

3

4

```
export const LoopTriggerSchema = z.discriminatedUnion("kind", [
  z.object(&lbrace; kind: z.literal("manual") &rbrace;),
  z.object(&lbrace; kind: z.literal("interval"), everyMs: z.number().int().positive() &rbrace;),
])
```

### Stage

| Field | Type | Notes |
| --- | --- | --- |
| `name` | `string` | Display label. |
| `agent` | `string` | Agent name (e.g. `build`, `plan`). |
| `model` | `"provider/model"` | Optional. Defaults to the session default. |
| `objective` | `string` | The prompt fed to the `goal` worker. |
| `tokenBudget` | `number` | Optional per-stage cap. |

### Definition

| Field | Type | Notes |
| --- | --- | --- |
| `id` | `string` | Stable identifier. |
| `name` | `string` | Display label. |
| `stages` | `Stage[]` | At least one stage. Stages run in order. |
| `trigger` | `LoopTrigger` | Manual or interval. |
| `maxRuns` | `number` | Optional temporal cap (interval loops stop after `maxRuns`). |
| `timeoutMs` | `number` | Per-run wall-clock cap. Defaults to `DEFAULT_RUN_TIMEOUT_MS = 60min`. |
| `createPR` | `boolean` | Optional. When true, the engine pushes the worktree to `loop/<id>` and runs `gh pr create` (or updates the existing PR) on every run that finishes with `status: "complete"`. Best-effort: missing git/gh/auth or no diffs are logged as warnings, the run still counts as `complete`. Default: `false`. |
| `paused` | `boolean` | Persisted pause flag; survives restarts. |
| `enabled` | `boolean` | Disabled loops never fire. |
| `createdAt` | `number` | Epoch ms. |

### Run

| Field | Type | Notes |
| --- | --- | --- |
| `id` | `string` | Unique run id. |
| `loopID` | `string` | Owning loop. |
| `startedAt` / `endedAt` | `number` | Epoch ms. |
| `status` | `LoopRunStatus` | `running | complete | error | timeout | cancelled | orphaned`. |
| `heartbeatAt` | `number` | Lease heartbeat, renewed while the owning process drives the run. |
| `sessionID` | `string` | Session id of the active stage worker, if any. |
| `error` | `string` | Surfaced error, if any. |
| `ok` | `boolean` | True when the run reached `complete`. |
| `pullRequest` | `LoopPullRequestRef` | Optional. Set when the engine successfully opened or updated a GitHub PR (only present if the definition has `createPR: true` and the hook succeeded). |

### Operational bounds

| Constant | Value | Purpose |
| --- | --- | --- |
| `MIN_INTERVAL_MS` | `30_000` | Smallest accepted interval. |
| `MAX_INTERVAL_MS` | `30 days` | Largest accepted interval (anti-abuse). |
| `MAX_CONCURRENT_RUNS` | `3` | Global cap on simultaneously running loops. |
| `MIN_RUN_TIMEOUT_MS` | `1_000` | Smallest accepted `timeoutMs`. |
| `DEFAULT_RUN_TIMEOUT_MS` | `60min` | Default per-run cap. |
| `MAX_RUN_TIMEOUT_MS` | `24h` | Largest accepted `timeoutMs`. |
| `LOOP_RUN_LEASE_MS` | `15_000` | Lease/heartbeat for orphan detection. |
| `HISTORY_LIMIT` | `50` | How many historical run records to retain per loop. |

## Engine lifecycle

1.  **Register** — the TUI or HTTP/SDK client calls `LoopEngine.upsert(def)`, which persists the definition through `LoopRepo` and arms (or re-arms) the interval timer in the core `Scheduler`.
2.  **Fire** — the `Scheduler` calls the engine's `run` callback. The engine claims the per-loop single-flight slot and a slot in the global `MAX_CONCURRENT_RUNS = 3` pool.
3.  **Drive** — the engine runs the stages in order. Each stage is a single `SessionPrompt.command({ command: "goal" })` call. The agent's own goal loop (`src/session/goal.ts`) does the until-done iteration.
4.  **Persist** — every stage transition renews the lease/heartbeat and writes a `LoopRun` record. Status flips to `complete`, `error`, or `timeout` when the run ends.
5.  **Reconcile** — on next boot, `restore()` finds every `running` run whose lease is older than `LOOP_RUN_LEASE_MS` and reconciles it to `orphaned`, then re-arms every interval loop.

Single-flight guarantees

The same loop can never run twice in parallel: a per-loop claim is held for the entire run. A second trigger that fires while the first is still active is dropped and counted in the run history as a fast skip.

## Triggers and stages

### Manual trigger

Manual loops never fire on a timer. They are started by the user through the TUI loop dialog or the HTTP/SDK interface. Use a manual loop when the work is infrequent or you want the user to opt in every time.

### Interval trigger

Interval loops fire every `everyMs` milliseconds. The smallest accepted interval is 30s; the largest is 30 days. The engine re-arms the timer on every run start and after every run finish.

maxRuns

An interval loop with `maxRuns` set will stop firing once the counter is reached. The loop stays in the list (paused) and you can resume it by clearing `maxRuns`.

### Stages

Stages run sequentially inside a single run. A stage's `objective` is the prompt fed to a fresh `goal` session. Stages share the loop's run id but each spawns its own session, so the previous stage's session is referenced by id but not reused.

text 

1

2

3

4

5

6

7

8

```
# A simple two-stage loop that triages issues then drafts a fix
stages:
  - name: triage
    agent: plan
    objective: "Triage the open issues in this repo and rank by impact."
  - name: draft-fix
    agent: build
    objective: "Pick the top issue and draft a fix as a draft PR."
```

## Auto-PR on completion

Set `createPR: true` on a loop definition to have the engine push the worktree and open (or update) a GitHub pull request every time a run finishes with `status: "complete"`. The engine reuses one branch per loop (`loop/<id>`), so a long-running loop accumulates its work on a single PR instead of opening a fresh one each tick.

Best-effort

The hook is intentionally best-effort: a missing `git` binary, a missing `gh` CLI, an unauthenticated `gh`, a non-GitHub remote, or an empty working tree all log a warning and let the run count as `complete` without a `pullRequest` on the run record.

When the hook succeeds, the run record is updated with a `pullRequest` block (number, url, branch, base, action: `created` or `updated`) and the TUI/mobile history view deep-links straight to the PR.

## HTTP and SDK operations

There is no top-level `nikcli loop` command. Manage loops through the TUI or the generated SDK, which calls the Effect HttpApi routes below.

| Route | Purpose |
| --- | --- |
| `GET /loop` | List loops and their current runtime state. |
| `PUT /loop` | Create a loop from a definition. |
| `GET /loop/:id` | Read a loop and its runtime state. |
| `POST /loop/:id/run` | Trigger an immediate run. |
| `POST /loop/:id/pause` | Stop firing triggers without aborting an in-flight run. |
| `POST /loop/:id/resume` | Re-enable the loop's triggers. |
| `POST /loop/:id/abort` | Abort the loop's in-flight run. |
| `DELETE /loop/:id` | Delete the loop and its run history. |

Terminal

bash 

```
curl http://127.0.0.1:4096/loop
curl -X POST http://127.0.0.1:4096/loop/lop_abc123/run
curl -X POST http://127.0.0.1:4096/loop/lop_abc123/pause
curl -X POST http://127.0.0.1:4096/loop/lop_abc123/resume
```

$ shell · paste into your terminal

## Bus events

The engine publishes typed bus events for clients to subscribe to. They live next to the engine in `packages/nikcli/src/loop/engine.ts`.

| Event | Payload | Use |
| --- | --- | --- |
| `loop.upserted` | `{ loopID }` | Definition created or replaced. |
| `loop.removed` | `{ loopID }` | Definition deleted. |
| `loop.run.started` | `{ loopID, runID, sessionID }` | A new run started. |
| `loop.run.finished` | `{ loopID, runID, status, ok, error? }` | Run ended. |
| `loop.runtime.changed` | `{ loopID, status, lastRunAt?, lastError? }` | Live runtime status flipped (idle, running, paused, error, cancelling). |

## TUI surface

The TUI loop feature-plugin lives in `packages/tui/src/feature-plugins/loops/`. It mirrors the mission plugin and exposes:

-   **Loop home view** — list of all loops with status chips, last run time, and quick actions (run, pause, resume, delete).
-   **Loop detail view** — per-stage list, current run highlight, run history (last 50), and the live session id of the active stage.
-   **Draft dialog** — pick a template, edit stages, and create a loop without leaving the TUI.
-   **Run dialog** — trigger an immediate run with optional one-off context (`--text`).

## Mobile surface

The mobile app exposes the same surface as a dedicated **Loops** tab. The screen lists every loop with a status chip, schedule summary, last-run timestamp, and quick actions. The detail screen edits the definition, fires manual runs, and pages through run history.

See [Mobile](/docs/mobile) for the full mobile walkthrough.

## Failure modes

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Run stuck in `running` after a crash | The orchestrator process died; the lease has expired. | Restart the host. `restore()` reconciles the run to `orphaned` and you can trigger a fresh run. |
| Trigger never fires | The loop is `paused` or `disabled`, or `maxRuns` has been reached. | Inspect `nikcli loop get <id>`; resume or re-create. |
| Stage never starts | Global cap of `MAX_CONCURRENT_RUNS = 3` is saturated. | Wait for in-flight runs to finish, or cancel one. |
| Single-flight slot held by an aborted run | Earlier process crashed without releasing the slot. | The lease reaper releases the slot after `LOOP_RUN_LEASE_MS` (15s). If it persists, restart the host. |
| Stage stays in `running` forever | Stage worker exceeded `timeoutMs`. | The engine aborts the worker via `prompt.cancel` and marks the run `timeout`. Inspect the worker session and re-run. |
| `createPR` is set but the run finishes with no `pullRequest` field | Missing git/gh, unauthenticated `gh`, non-GitHub remote, or no working-tree diff. | Check the engine logs for the warning; the run still counts as `complete`. Install/authenticate `gh`, point `origin` at a GitHub repo, or commit changes before the run ends. |

## Source anchors

| File | What to inspect |
| --- | --- |
| `packages/nikcli/src/loop/schema.ts` | Zod schemas for `LoopDefinition`, `LoopStage`, `LoopTrigger`, `LoopRun`, and the operational bounds. |
| `packages/nikcli/src/loop/manager.ts` | CRUD and run-record persistence. |
| `packages/nikcli/src/loop/engine.ts` | Headless engine: `Scheduler` integration, single-flight, concurrency cap, lease/heartbeat, bus events, auto-PR hook. |
| `packages/nikcli/src/loop/pr.ts` | Best-effort GitHub PR creation: branch naming, push, `gh pr create` / `gh pr edit`. |
| `packages/tui/src/feature-plugins/loops/` | TUI feature plugin: store, SDK, runner, dialogs. |
| `packages/mobile/app/(app)/loops/` | Mobile Loops tab and detail screen. |
| `specs/10-loops.md` | Long-form design document for the loops system. |

---

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