Skip to content
nikcli
Docs
Documentation

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

TermDefinition
LoopNamed, persisted unit: a trigger, a list of stages, optional maxRuns and timeoutMs.
StageOne unit of work within a run: name, agent, optional model, objective, optional token budget.
Trigger{ kind: "manual" } or { kind: "interval", everyMs }.
RunA single execution of the loop's stages. Carries lease/heartbeat and a session id per stage.
RuntimeLive, in-memory status of a loop (idle | running | paused | error | cancelling), updated by the engine on every bus event.
TemplateA 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
export const LoopTriggerSchema = z.discriminatedUnion("kind", [
  z.object({ kind: z.literal("manual") }),
  z.object({ kind: z.literal("interval"), everyMs: z.number().int().positive() }),
])

Stage

FieldTypeNotes
namestringDisplay label.
agentstringAgent name (e.g. build, plan).
model"provider/model"Optional. Defaults to the session default.
objectivestringThe prompt fed to the goal worker.
tokenBudgetnumberOptional per-stage cap.

Definition

FieldTypeNotes
idstringStable identifier.
namestringDisplay label.
stagesStage[]At least one stage. Stages run in order.
triggerLoopTriggerManual or interval.
maxRunsnumberOptional temporal cap (interval loops stop after maxRuns).
timeoutMsnumberPer-run wall-clock cap. Defaults to DEFAULT_RUN_TIMEOUT_MS = 60min.
createPRbooleanOptional. 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.
pausedbooleanPersisted pause flag; survives restarts.
enabledbooleanDisabled loops never fire.
createdAtnumberEpoch ms.

Run

FieldTypeNotes
idstringUnique run id.
loopIDstringOwning loop.
startedAt / endedAtnumberEpoch ms.
statusLoopRunStatusrunning | complete | error | timeout | cancelled | orphaned.
heartbeatAtnumberLease heartbeat, renewed while the owning process drives the run.
sessionIDstringSession id of the active stage worker, if any.
errorstringSurfaced error, if any.
okbooleanTrue when the run reached complete.
pullRequestLoopPullRequestRefOptional. 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

ConstantValuePurpose
MIN_INTERVAL_MS30_000Smallest accepted interval.
MAX_INTERVAL_MS30 daysLargest accepted interval (anti-abuse).
MAX_CONCURRENT_RUNS3Global cap on simultaneously running loops.
MIN_RUN_TIMEOUT_MS1_000Smallest accepted timeoutMs.
DEFAULT_RUN_TIMEOUT_MS60minDefault per-run cap.
MAX_RUN_TIMEOUT_MS24hLargest accepted timeoutMs.
LOOP_RUN_LEASE_MS15_000Lease/heartbeat for orphan detection.
HISTORY_LIMIT50How 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
# 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.

RoutePurpose
GET /loopList loops and their current runtime state.
PUT /loopCreate a loop from a definition.
GET /loop/:idRead a loop and its runtime state.
POST /loop/:id/runTrigger an immediate run.
POST /loop/:id/pauseStop firing triggers without aborting an in-flight run.
POST /loop/:id/resumeRe-enable the loop's triggers.
POST /loop/:id/abortAbort the loop's in-flight run.
DELETE /loop/:idDelete 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

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.

EventPayloadUse
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

SymptomLikely causeFix
Run stuck in running after a crashThe 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 firesThe loop is paused or disabled, or maxRuns has been reached.Inspect nikcli loop get <id>; resume or re-create.
Stage never startsGlobal 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 runEarlier 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 foreverStage 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 fieldMissing 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

FileWhat to inspect
packages/nikcli/src/loop/schema.tsZod schemas for LoopDefinition, LoopStage, LoopTrigger, LoopRun, and the operational bounds.
packages/nikcli/src/loop/manager.tsCRUD and run-record persistence.
packages/nikcli/src/loop/engine.tsHeadless engine: Scheduler integration, single-flight, concurrency cap, lease/heartbeat, bus events, auto-PR hook.
packages/nikcli/src/loop/pr.tsBest-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.mdLong-form design document for the loops system.