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.
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.
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.
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).
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
export const LoopTriggerSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("manual") }),
z.object({ kind: z.literal("interval"), everyMs: z.number().int().positive() }),
]) 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
- Register — the TUI or HTTP/SDK client calls
LoopEngine.upsert(def), which persists the definition throughLoopRepoand arms (or re-arms) the interval timer in the coreScheduler. - Fire — the
Schedulercalls the engine'sruncallback. The engine claims the per-loop single-flight slot and a slot in the globalMAX_CONCURRENT_RUNS = 3pool. - 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. - Persist — every stage transition renews the lease/heartbeat and writes a
LoopRunrecord. Status flips tocomplete,error, ortimeoutwhen the run ends. - Reconcile — on next boot,
restore()finds everyrunningrun whose lease is older thanLOOP_RUN_LEASE_MSand reconciles it toorphaned, then re-arms every interval loop.
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.
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.
# 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.
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. |
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 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 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. |