loop-task

Recipe Loops

Version-control your loops as YAML files in .loops/recipes/. Auto-discovered, hot-reloaded, and team-shareable.

Recipe Loops let you define loops and their task chains as YAML files inside your repository. Drop a .yaml file into .loops/recipes/ and loop-task auto-discovers it, creates the loop with its chained tasks, and watches the file for changes. Recipes are version-controlled alongside your code, so your team shares the same loop definitions without manual loop-task new commands or exports.

How it works

  1. You create a .loops/recipes/ directory in your project root (the same directory the daemon runs from).
  2. You drop one or more *.yaml files into it. Each file is one recipe: one loop plus its task chain.
  3. The daemon's file watcher detects the file, validates it, creates the loop and tasks, and schedules the loop.
  4. If you edit the recipe file later, the daemon reloads it in place. If you delete it, the loop is removed.

Every recipe loop is tagged with isRecipe: true in its metadata. Recipe loops appear alongside normal loops in loop-task status and the TUI board, but they have special rules: they cannot be deleted via CLI or HTTP API (remove the file instead), and runtime overrides are written back to the YAML file.

Directory structure

your-project/
├── .loops/
│   └── recipes/
│       ├── issue-refiner.yaml
│       ├── ci-watcher.yaml
│       └── backlog-sweeper.yaml
├── src/
└── package.json

Place recipe files at .loops/recipes/*.yaml relative to the project root (the working directory the daemon was started from). The .yml extension is also accepted.

Recipe schema

Every recipe file is a YAML mapping with three top-level keys (plus an optional diagram key for ASCII art documentation):

KeyTypeRequiredDescription
versionnumberYesSchema version. Must be 2.
loopsarrayYesExactly one loop entry (see below).
tasksarrayYesOne or more task definitions that form the chain.
diagramstringNoASCII art diagram of the task chain, stored as a YAML block scalar.

Loop entry fields

FieldTypeDefaultDescription
taskIdstringID of the first task to run (references a task in tasks[]). If omitted, the loop runs its command directly.
intervalnumberInterval in milliseconds. Use intervalHuman instead for readability.
intervalHumanstringHuman-readable interval: 10s, 5m, 1h, 1d, manual.
immediatebooleanfalseRun immediately on start, before waiting for the first interval tick.
maxRunsnumber | nullnullStop the loop after N executions. null means run forever.
verbosebooleanfalseShow execution details in logs.
descriptionstringHuman-readable description shown in the TUI board and status output.
offsetnumber | nullnullPhase offset in milliseconds for spread scheduling.
contextobjectInitial context passed to the task chain (e.g. {env: staging}).
cwdstringWorking directory for the command.
commandstringShell command to execute (used when there are no tasks).
commandArgsstring[]Arguments for command, passed as an array.

Task entry fields

FieldTypeDefaultDescription
idstringRequired. Unique identifier for the task within this recipe. Used by onSuccessTaskId and onFailureTaskId to chain tasks.
namestringidDisplay name shown in the TUI board.
commandstring""Shell command to execute.
commandArgsstring[][]Arguments for command, passed as an array.
commandRawstringRaw command string (alternative to command + commandArgs).
onSuccessTaskIdstring | nullnullTask ID to run on success (exit code 0).
onFailureTaskIdstring | nullnullTask ID to run on failure (non-zero exit code).
silentChainbooleanfalseSuppress this task's output from the chain context (useful for error handlers that should not pollute downstream context).
contextobjectStatic context merged into the shared context when this task runs.
stepsarrayMulti-step task definition. Each step has a commands array of { command, commandArgs } objects.

Complete example

Here is a real recipe from this project's own .loops/recipes/ directory. It fetches all repo issues, extracts the oldest one, then prints it using template interpolation:

version: 2

loops:
  - taskId: list-issues
    intervalHuman: "0"
    description: List all repo issues (open + closed), print the oldest

tasks:
  - id: list-issues
    name: List all issues
    command: gh
    commandArgs:
      - issue
      - list
      - --state
      - all
      - --json
      - number,title,createdAt
      - --limit
      - "500"
      - --jq
      - "sort_by(.createdAt) | {oldestNumber: .[0].number, oldestTitle: .[0].title, oldestCreatedAt: .[0].createdAt, total: length}"
    onSuccessTaskId: print-oldest
    onFailureTaskId: null

  - id: print-oldest
    name: Print oldest issue
    command: echo
    commandArgs:
      - "oldest issue: #{{oldestNumber}} {{oldestTitle}} ({{oldestCreatedAt}}) - total: {{total}}"
    onSuccessTaskId: null
    onFailureTaskId: null

diagram: |
  ┌─────────────┐       ┌─────────────┐
  │ list-issues │──✓──▶│ print-oldest │──▶ end
  └─────────────┘       └─────────────┘

How the chain works

  1. The loop starts with list-issues (referenced by taskId in the loop entry).
  2. list-issues runs gh issue list and outputs a JSON object with keys like oldestNumber, oldestTitle, oldestCreatedAt, and total.
  3. loop-task parses that JSON into the shared context.
  4. On success, print-oldest runs. It uses {{oldestNumber}}, {{oldestTitle}}, etc. — these are replaced with the values from the context.
  5. If list-issues fails (non-zero exit), the chain stops because onFailureTaskId is null.

See Task Chaining for the full context-sharing and template-interpolation reference.

Verify and fix recipe

This recipe runs lint, tests, and a build on a cadence. If the verify task fails, an AI agent reads the errors from {{output}} and fixes them. The fix chains back to verify, so the pipeline retries until everything passes. On success, verify chains to commit.

version: 2

loops:
  - taskId: verify
    intervalHuman: "30m"
    description: Verify code, then fix failures with an AI agent
    immediate: true

tasks:
  - id: verify
    name: Lint, test, build
    command: sh -c 'pnpm lint && pnpm test && pnpm build'
    onSuccessTaskId: commit
    onFailureTaskId: fix

  - id: fix
    name: AI fix
    command: opencode run --agent fullstack-engineer "Fix errors: {{output}}"
    onSuccessTaskId: verify
    onFailureTaskId: null

  - id: commit
    name: Commit clean build
    command: sh -c 'git add -A && git commit -m "verified: lint, tests, build"'
    onSuccessTaskId: null
    onFailureTaskId: null

diagram: |
  ┌────────┐       ┌────────┐
  │ verify │───✓──▶│ commit │──▶ end
  └────────┘       └────────┘



  ┌────────┐       ┌────────┐
  │  fix   │───✓──▶│ verify │ (retry)
  └────────┘       └────────┘

When verify fails, its stderr (compiler errors, test failures, lint violations) is merged into the {{output}} context key. The fix task interpolates {{output}} into its prompt, so the agent sees the same error output you would see in your terminal. Once the agent fixes the code, fix succeeds and chains back to verify, which re-runs the checks. The loop continues until verify passes and chains to commit.

Error handling with silent chains

Use silentChain: true on error-handler tasks to prevent their output from overwriting the shared context. This is useful when a failure handler logs a message but should not pollute downstream data:

- id: log-silent-error
  name: Log error (silent)
  command: echo
  commandArgs:
    - "gh failed or not authenticated, nothing to report"
  silentChain: true
  onSuccessTaskId: null
  onFailureTaskId: null

Runtime overrides

You can override certain loop properties at runtime (interval, max runs, project assignment) via the HTTP API PATCH /api/loops/:id endpoint or the TUI board. When you do, loop-task writes the override back to the recipe YAML file so it persists across daemon restarts:

curl -X PATCH http://127.0.0.1:8845/api/loops/<loop-id> \
  -H "Content-Type: application/json" \
  -d '{"intervalHuman": "1h", "maxRuns": 100}'

The recipe file on disk is updated with the new values. The file remains the source of truth — if you later edit it manually, the daemon hot-reloads your changes.

Lifecycle rules

ActionEffect
Create a .yaml file in .loops/recipes/Loop is created and scheduled immediately.
Edit a .yaml fileLoop is reloaded in place. Existing run history is preserved. Task IDs are remapped to new random IDs.
Delete a .yaml fileLoop is removed from the daemon. Run history and logs are kept on disk.
Delete via CLI (loop-task rm <id>)Returns 403 — recipe loops cannot be deleted this way. Remove the file instead.
Delete via HTTP API (DELETE /api/loops/:id)Returns 403 — same restriction.

Recipe loops vs. normal loops

PropertyNormal loopRecipe loop
Created vialoop-task new, TUI, or HTTP API.loops/recipes/*.yaml file
Stored inloops.json (daemon state).loops/recipes/ (your repo)
DeletableYes, via loop-task rm or APINo — remove the file instead
Version controlledOnly via loop-task exportYes — lives in your repository
Hot-reloadedNo (requires restart or re-import)Yes (file watcher)
Runtime overridesWritten to daemon stateWritten back to the YAML file
Appears in status / TUIYesYes (with recipe badge)

Tips

  • One file per loop. Each recipe is a single loop with its task chain. If you need multiple loops, create multiple files.
  • Use intervalHuman instead of raw milliseconds for readability. Both work, but 30m is clearer than 1800000.
  • Start with manual interval. Set intervalHuman: "manual" while developing a recipe so it only runs when triggered, then switch to your real interval once it works.
  • Test with --max-runs 1. Add maxRuns: 1 during development so the loop runs once and stops, making it easy to inspect the output.
  • Commit recipes to your repo. They are plain YAML files — your team gets the same loop definitions when they clone the repository.