looop 0.25.0

A tiny, portable, Kubernetes-shaped control loop for your work
looop-0.25.0 is not a library.

looop

A tiny, portable control plane for agent-driven work.

looop watches the things you care about (GitHub, Linear, Grafana, …) and runs your worker fleet. It is autonomous: each beat it senses the world and, when something changed, decides the single most important move and executes it — spawning worker agents for hands-on work. The judgment lives inside looop (a small, gated LLM call per beat); looop is the brain.

You are a peer, not the driver: you steer by editing goals / the PLAYBOOK, and a worker that hits a decision only a human can make asks you and waits. To watch and steer in plain language you can run a concierge — a pi/claude session pointed at looop that reads its state and relays — but looop runs fine without one. One self-contained binary, no database, no server.

looop running a tick

How it works

looop is the brain; workers are the hands; you (optionally via a concierge) are the peer who shapes goals and answers what only a human can.

   pulse (looop — autonomous)              workers            concierge (optional)
   ──────────────────────────             ───────            ────────────────────
   each beat: sense the world,            real agents        a pi/claude YOU run:
   if it changed → decide ONE     ──▶     doing multi-       reads `looop _ state`,
   move → execute it (gated)              step work          relays asks to you,
   (skips the LLM if unchanged)           `ask` + wait  ──▶  helps you edit goals
  1. SENSE — the pulse runs every sensors/*.sh each beat, keeping snapshots/ fresh. If the world is unchanged since last beat it stops here — no LLM call, nearly free.
  2. DECIDE — when the world changed, looop hands the PLAYBOOK + goals + readings + pending asks to the tick runner, which emits ONE typed move.
  3. ACT — looop executes that move (and gates risky ones): write a goal/sensor/PLAYBOOK, run one reversible shell command, or start a worker. One move per beat; a daily budget breaker caps spend.
  4. HUMAN — you steer by editing goals/PLAYBOOK (observed next beat); a worker that needs a human decision asks and waits. Irreversible things never happen without your explicit yes.

State lives entirely in files (goals, snapshots, journal, mailbox), so it is level-triggered: the pulse re-senses from scratch every beat and a crashed pulse just re-reads the unanswered asks on restart.

The human-in-the-loop path is a durable mailbox, not a tmux prompt: a worker that needs a decision runs one blocking looop _ ask … and waits; you answer with looop _ answer (directly or through the concierge). No attach, no stdin wrangling — it works for headless workers.

Concepts

Everything lives as plain files in the data dir (the loop's memory):

File / dir Role (Kubernetes analogy)
PLAYBOOK.md the controller logic — your judgment, priorities, guardrails
goals/*.md desired state — one declarative spec per thing you're pushing
sensors/*.sh observers — each prints one JSON object describing the world
journal.md the action log — one line per move
claims/ leases — a worker writes one to own a task; stale ones auto-reap
reports/ deliverables a human reads (persists across beats)
asks/ answers/ the worker ↔ human mailbox (questions + answers)

Workers are the hands. When a move needs real, multi-step work, looop spawns an agent session that runs detached, in parallel, and reconciles its task on its own. Workers that touch code provision their own sandbox first; looop itself knows nothing about repos.

Humans in the loop. looop decides on its own — you shape WHAT it pursues by editing goals / the PLAYBOOK (it observes the change next beat). A worker that needs a decision only a human can make runs looop _ ask and blocks; you answer with looop _ answer — directly, or through a concierge (a pi/claude session you point at looop that surfaces pending asks and helps you edit goals). The concierge is an interface, not a decision-maker. Irreversible actions (merges, deploys, deletes) always require your explicit approval via an ask.

Quick start

looop up            # start the autonomous pulse (sense + decide + run workers), detached
looop watch         # (optional) live colored log + running-session selector
# (optional) run a concierge to watch + steer in plain language:
pi                  # then say: "be my looop concierge — show me `looop _ state`,
                    #            relay pending asks, help me edit goals; read `looop --help`"
looop down          # stop the pulse and all workers

looop up starts the autonomous pulse — looop runs itself from there. You steer by editing goals/PLAYBOOK and answering asks (looop _ answer); the concierge is optional sugar for doing that in chat. looop up --json makes the pulse log machine-readable NDJSON.

On the first run looop seeds a starter PLAYBOOK and a setup goal whose only job is to invite you to configure it (a journal note the concierge relays) — run a concierge (or edit goals/PLAYBOOK directly) to replace the starter with your real work. After that it just runs.

Commands

# HUMAN (looop runs itself — this is nearly all you touch)
looop up [--json]              start the autonomous pulse (detached)
looop down                     stop the pulse and all workers
looop watch [<id>]             observer TUI: live colored log + session selector
looop cost [today|all|--json]  report LLM spend (per-beat decide + workers)
looop config zsh|bash          print shell integration (tab completions)
looop version | help           (looop help = the full design manual)

# STEER (you, or a concierge acting for you — looop does NOT need these to act)
looop _ state [--json]                     read current world state
looop _ wait [--json] [--only-asks|--actionable]   block until change; prints a
                                           `changed: []` diff (--actionable =
                                           asks/journal only, --only-asks = asks)
looop _ asks [--json]                      pending asks only (concierge's narrow view)
looop _ answer <ask_id> "<text>"|- [--force]  resolve an ask (`-`/empty body = stdin/heredoc; --force to overwrite an already-answered ask)
looop _ goal write <id> [body|stdin] | _ goal archive <id>
looop _ sensor write <name> [script|stdin] | _ playbook write [body|stdin]

# WORKER self-callbacks (auto-injected contract — not human commands)
looop _ ask <id> --prompt "…" [--ref P] [--options a,b]   ask + block for answer
looop _ kill <id> | _ claim <name> | _ unclaim <name> | _ cost <…>

The human surface is tiny — essentially up/down/watch (plus cost/config). looop decides autonomously; the looop _ … STEER verbs let you (or a concierge) inspect state, answer asks, and edit goals/sensors/PLAYBOOK by hand, and workers self-report (ask, kill, claim, unclaim, cost) via the auto-injected contract.

Sensors

A sensor is a script in sensors/ that prints one JSON object to stdout each beat — looop's window onto the outside world. looop itself knows nothing about GitHub, Linear, Grafana, …; you teach it by dropping a sensor. The pulse runs every sensors/*.sh each beat and stores the output in snapshots/<name>.json.

Split the JSON into two keys:

  • signal — the part that should WAKE the loop. A change here flips the world hash, so the next beat re-decides (and _ wait reports it). Keep it small and stable: counts, states, ids — not timestamps.
  • detail — volatile context that reaches the decide prompt but NEVER wakes the loop (e.g. "last checked at …"). Without a signal key the whole object is treated as the wake signal.

Example: a GitHub PR-review sensor

Surface stale CHANGES_REQUESTED PRs so looop (and the concierge via _ state) sees review state without anyone shelling out to gh:

# sensors/gh.sh  (requires an authenticated `gh`)
#!/usr/bin/env bash
set -euo pipefail
cr=$(gh pr list --search "review:changes-requested" --json number --jq 'length')
open=$(gh pr list --state open --json number --jq 'length')
# signal wakes the loop on a count change; detail is just context for the prompt.
jq -cn --argjson cr "$cr" --argjson open "$open" \
  '{signal: {open_prs: $open, changes_requested: $cr},
    detail: {checked_at: (now | todate)}}'

Install it with looop _ sensor write gh < sensors/gh.sh (or drop the file in the data dir's sensors/ directly). Its reading then shows up in looop _ state:

sensors:
  gh: {"changes_requested":1,"open_prs":2}

and a change to changes_requested wakes the loop so the PLAYBOOK can react (e.g. spawn a worker to nudge the PR, or ask you to merge).

Shell integration

# Zsh (~/.zshrc)
eval "$(looop config zsh)"

# Bash (~/.bashrc)
eval "$(looop config bash)"

This adds tab completion for looop's (small) human command surface.

To change judgment: run looop _ playbook write (or edit a goal) — looop picks it up next beat.

Install

curl (recommended)

Downloads a prebuilt binary from GitHub Releases — no Rust toolchain needed:

curl -fsSL https://raw.githubusercontent.com/yusukeshib/looop/main/install.sh | bash

Installs looop to ~/.local/bin/looop (override with LOOOP_INSTALL_DIR). The script falls back to cargo install / nix profile install if no prebuilt binary matches your platform. Make sure the install dir is on your PATH:

export PATH="$HOME/.local/bin:$PATH"

Cargo

cargo install looop

Nix (flakes)

nix run github:yusukeshib/looop                 # run without installing
nix profile install github:yusukeshib/looop     # install into your profile
nix develop github:yusukeshib/looop             # dev shell (cargo, clippy, rustfmt)

From git (latest main)

cargo install --git https://github.com/yusukeshib/looop.git --locked looop

Verify

looop version   # prints the installed version (e.g. looop 0.13.0)
looop help

Runtime deps: just an LLM runner (pi or claude) — used for looop's per-beat decide (tick) and to launch workers. looop is a single self-contained binary — spawning, listing, killing and pruning sessions all run in-process, no extra executable required. Sessions are stored under $LOOOP_DATA_DIR/sessions, self-contained per profile: looop sets no extra environment and shares no global state, and session ids are bare (the pulse is pulse). (Workers that touch code also need git or box to sandbox themselves, but that's a worker concern.)

Config & data

  • Config$LOOOP_DATA_DIR/config.json (override LOOOP_CONFIG). Lives inside the data dir so a profile is fully self-contained. One file: runner wiring (a tick command for the per-beat decide and an interactive command to launch workers, per runner) plus the pulse interval and optional max_daily_usd budget. Default runner is pi; claude is built in.
  • Data / memory$XDG_STATE_HOME/looop/ (override LOOOP_DATA_DIR). A plain directory holding the PLAYBOOK, goals, journal, and sensors. looop does not version it for you — git init the data dir yourself if you want history and rollback of your policy files. Worker and pulse sessions live under sessions/ in the same dir, so a profile is fully self-contained. Pointing LOOOP_DATA_DIR elsewhere gives you an isolated profile with its own sessions.

LLM spend is recorded in an append-only ledger — looop meters its own per-beat decide in-process, and workers self-report via looop _ cost; see looop cost. Set max_daily_usd in the config to arm a daily budget breaker that skips the AI once today's spend hits the cap (clears at local midnight).