Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

loop.js

Loop.js is the framework for loop engineering.

You state a goal and what "done" means. The engine runs an agent at it, Round after Round — fresh context every Round, memory read back from disk — until a separate, skeptical judge rules the bar met. Run it from a terminal, schedule it — on your machine or deployed to Modal's cloud — or embed it in a product: same engine, same guarantees.

npm license docs

import { Loop } from "@loop.js/core"

const loop = Loop.define({
  goal: "Build a playable 2D platformer",
  verify: "It builds clean, `bun test` passes, and the game boots to a controllable character",
  limits: { rounds: 20, usd: 10 },
})

const exit = await loop.run().done()
// { settled: true, verdict: { ok: true, reason: "…" } } — the judge said done, not the worker

Loop engineering is designing the system that prompts an agent instead of prompting it turn by turn: a goal, a way to work, a way to verify, a stop discipline — the loop does the iterating. loop.js is that system as a framework: what is loop engineering →

Why loops need a framework

Running an agent in while (true) is one line. Whether that loop ever converges is everything around it — and that is the part loop.js owns:

The pain The machinery
The agent says "done" when it isn't A separate Verify agent — own session, own (cheaper, if you like) model, read-only by default — judges every Round. Only its verdict settles the Loop; the worker never grades its own homework.
Long sessions bloat and drift Every Round starts with fresh context and reads its memory back from disk — the worker's own handoff notes. Round 40 starts as fresh as Round 1.
Retries that don't converge A "not yet" verdict carries a mandatory reason, fed to the next Round. A goal that can never pass settles as an explicit give-up instead of burning the budget.
Money runs away Declared guards: total usd (step-granular ledger), rounds, per-Round timeout — and they default tight ($1, 3 Rounds, 5m). Every ending is a typed Exit with its own process exit code.
Crashes, restarts, double triggers All state lives on disk; loop run is idempotent and resumes from the cursor. The Lock (compare-and-set + heartbeat) refuses a live owner and takes over a dead one — any trigger cadence is overlap-safe.
Babysitting loop cron installs into a real scheduler — crontab, launchd, Task Scheduler, or Modal in the cloud. No daemon, ever.

What do you loop?

Build something until it's actually done. The verdict — not vibes — decides when to stop:

// loop.config.ts
export default Loop.define({
  goal: "Build a playable 2D platformer — arrow keys, jump physics, win and lose states",
  verify: "`bun test` passes and `bun run build` emits a bundle that boots without console errors",
  limits: { rounds: 30, usd: 15, timeout: "20m" },
})
npx loop run   # Rounds stream by until the judge says ok — or a guard fires, with its own exit code

Keep something true, on a schedule. A time-dependent goal goes stale by itself — each morning the settled Loop is re-judged: yesterday's brief no longer satisfies "today's brief exists", so the Loop re-opens and writes a new one. On days the bar still holds, the trigger costs one judge turn and re-settles.

export default Loop.define({
  goal: "Today's brief on my watchlist (NVDA, TSLA, BTC) exists in ./briefs, named by date — overnight moves, headlines, one paragraph of context each",
  verify: { model: "claude-haiku-4-5" }, // bar = the goal itself; judged by a cheaper model
  permissions: "bypass",                 // gating off: headlines need the network — run this loop inside your own container
})
npx loop cron add "0 8 * * 1-5" --until forever   # weekday mornings, until you remove it

Trade a strategy, audited every day. The worker trades through your broker's API; the judge audits every order against the strategy — and a breach settles as a give-up, so the loop never retries its way into more orders:

export default Loop.define({
  goal: "Today's trades are executed and logged in ./trades, dated — follow ./strategy.md: check its signals, size positions within its caps, attach a stop-loss to every order",
  verify: "Every order in today's log matches a strategy.md rule, respects its caps, carries a stop-loss, and reconciles with the broker's confirmations — any breach is a give-up, not a retry",
  permissions: "bypass", // broker API + market data need the network — run contained
  limits: { rounds: 3, usd: 2 },
})
npx loop cron add "30 9 * * 1-5" --until forever --backend modal   # weekday mornings — deployed to Modal, no machine of yours stays on

The usd guard caps what the loop spends on the model, never what the strategy trades — position caps live in strategy.md and in your broker account's own limits.

Chores that should stay done. Same shape, pointed at upkeep — schedule with --until settled (the entry removes itself at the first settle) or --until forever:

  • "Dependencies are current, bun test is green, and the changelog has an entry" — weekly
  • "Every new issue has a triage label and a first response" — nightly
  • "Every TODO in ./src links an issue or is deleted"
  • "Test coverage ≥ 80%, no skipped tests"
  • "Yesterday's ETL output exists in ./data and passes its sanity checks"
  • "Every post in ./posts has an up-to-date Chinese translation"

One pass, no judging. Agent.define is the same Execute phase run bare — one ungraded pass, no verdict, no convergence machinery:

import { Agent } from "@loop.js/core"

await Agent.define({ goal: "Summarize yesterday's git log into ./standup.md" }).run().done()

Quickstart

npm create @loop.js@latest my-loop
cd my-loop && npm install
export ANTHROPIC_API_KEY=sk-ant-…   # agents run on the Claude Agent SDK

npx loop run      # drives Rounds until the Loop settles or a guard fires
npx loop status   # check on it any time, from any shell — human or --json

The scaffold is goal-only: goal is the field you edit; the limits block spells out the tight engine defaults — 3 Rounds, $1, 5 minutes per Round — and every other knob is a commented line carrying its default. First runs stop cheap by design; raising the guards is the deliberate act. Full quickstart →

Schedule it — local, or deployed to Modal

loop cron installs an Entry into a real scheduler and never runs one itself. A fired Entry simply runs loop run — the Lock makes any cadence overlap-safe — and every Entry declares its own lifetime at add:

npx loop cron add "*/30 * * * *" --until settled                # a watchdog: gone at the first settle (capped)
npx loop cron add "0 8 * * *"    --until forever                # standing: each tick re-judges through the Verify gate
npx loop cron add "0 8 * * *"    --until forever --backend modal   # the same Entry, deployed to Modal's cloud
npx loop cron list
npx loop cron remove <id>
Backend Where it runs Where State lives
local (default) crontab (Linux), launchd (macOS), Task Scheduler (Windows) the project directory
modal a modal.Cron fires an ephemeral Runner per tick — no machine of yours stays on a Modal Volume — remove deletes the Entry, never its Volume

With --backend modal, add deploys with your own Modal token and stores your ANTHROPIC_API_KEY once as a shared modal.Secret named loop-js — created only when absent, rotated with one modal secret create --force, no redeploy. Scheduling →

One Round

               Goal — what "done" means; required, judged every Round
                 │
   ┌─────────────┼─────────────────── one Round ───────────────────────┐
   │             ▼                                                     │
   │  Execute   the worker agent builds in the work tree               │
   │     │      (fresh context; memory read back from disk)            │
   │     ▼                                                             │
   │  Handoff   the worker writes a note to its successor              │
   │     ▼                                                             │
   │  Verify    a separate, skeptical agent judges against the bar     │
   │     │      (read-only; escalates to the tree, the build, the      │
   │     ▼       transcript when a claim needs ground truth)           │
   │  Persist   record + journal — resumable after any crash           │
   └─────────────┬─────────────────────────────────────────────────────┘
                 ▼
        ok          → settled: the Loop succeeds
        not yet     → the reason lands on disk and feeds the next Round
        impossible  → settled: explicit give-up, budget preserved

Only a verdict settles a Loop — rounds, usd, and timeout are runaway guards, never a definition of done.

Goal · Execute · Verify

Everything you author is a prompt — one shape, three homes. A prompt is a string, a { file: "./verify.md" } (re-read fresh every Round, so you can move the bar mid-loop), or a per-round function.

         goal          what "done" means — the one required thing
        /    \
  execute    verify    the mirrored pair — optional, each falls back to the goal
  how to work it       how to judge it
Loop.define({
  goal: "…",
  execute: { file: "./execute.md" },   // what to work on each Round
  verify: "the checks to run and the end-state that must hold",
})

// the object form binds a phase's model or permissions:
Loop.define({
  goal: "…",
  verify: { prompt: { file: "./verify.md" }, model: "claude-haiku-4-5" },
})

Goal-only is first-class: omit both and the engine works toward — and judges against — the goal itself. Prompts →

Embed it

The same engine is a typed library. loop.run() self-drives and returns an async-iterable handle — iterating observes, it doesn't control; breaking out unsubscribes without cancelling:

const run = loop.run()
for await (const e of run) {
  if (e.type === "text-delta") ui.type(e.text)                // live typewriter
  if (e.type === "verdict")    ui.badge(e.round, e.ok, e.reason)
  if (e.type === "exit")       ui.done(e.exit)                // terminal — stream completes
}

Every event is typed, and all but the stream-only text-delta are journaled; startup failures throw, everything after resolves to a final exit — iterating never throws. Events →

Neighbors

Shell loops (the ralph tradition) proved the core insight: re-prompt with fresh context, keep state on disk. loop.js keeps that and adds what a bare loop can't give you — an independent verdict with mandatory reasons, typed guards and exits, crash-safe resume, real schedulers. Where loop.js stands →

Claude Code's /goal, /loop, /schedule are the right tool for driving your own session. Reach for loop.js when the loop outlives your terminal: someone else (or cron) triggers it, money is on the line, or "done" must be judged by an agent the worker can't influence. Full comparison →

CLI

Command What it does
loop run drives Rounds until the Loop settles or a guard fires; the exit code says which
loop status one snapshot of what has happened, from disk — human-readable or --json
loop cron install / list / remove schedule entries — local OS scheduler or Modal

Status

v0.2 beta. Shipping today: the engine (Rounds, verdicts, budgets, Lock, journal), the CLI (run / status / cron with local and Modal backends), and the Claude Agent SDK executor — tested at every boundary (Lock CAS, event ordering, crash-partial folding; 800+ tests). Ahead of 1.0: sandbox-contained runs, a public executor interface, remote observation. Pre-1.0 the API may still move.

Packages

Package What it is
@loop.js/core engine + loop CLI
@loop.js/create the scaffolder behind npm create @loop.js

License

Apache-2.0

Releases

Packages

Contributors

Languages