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.
No daemon restart needed
Recipes are discovered and hot-reloaded automatically. Add a file, edit a file, or delete a file — the daemon picks up the change without restarting.
How it works
- You create a
.loops/recipes/directory in your project root (the same directory the daemon runs from). - You drop one or more
*.yamlfiles into it. Each file is one recipe: one loop plus its task chain. - The daemon's file watcher detects the file, validates it, creates the loop and tasks, and schedules the loop.
- 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.jsonPlace 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):
| Key | Type | Required | Description |
|---|---|---|---|
version | number | Yes | Schema version. Must be 2. |
loops | array | Yes | Exactly one loop entry (see below). |
tasks | array | Yes | One or more task definitions that form the chain. |
diagram | string | No | ASCII art diagram of the task chain, stored as a YAML block scalar. |
Exactly one loop per file
Each recipe file must contain exactly one entry in the loops array. If you need multiple loops, create separate recipe files.
Loop entry fields
| Field | Type | Default | Description |
|---|---|---|---|
taskId | string | — | ID of the first task to run (references a task in tasks[]). If omitted, the loop runs its command directly. |
interval | number | — | Interval in milliseconds. Use intervalHuman instead for readability. |
intervalHuman | string | — | Human-readable interval: 10s, 5m, 1h, 1d, manual. |
immediate | boolean | false | Run immediately on start, before waiting for the first interval tick. |
maxRuns | number | null | null | Stop the loop after N executions. null means run forever. |
verbose | boolean | false | Show execution details in logs. |
description | string | — | Human-readable description shown in the TUI board and status output. |
offset | number | null | null | Phase offset in milliseconds for spread scheduling. |
context | object | — | Initial context passed to the task chain (e.g. {env: staging}). |
cwd | string | — | Working directory for the command. |
command | string | — | Shell command to execute (used when there are no tasks). |
commandArgs | string[] | — | Arguments for command, passed as an array. |
Task entry fields
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. Unique identifier for the task within this recipe. Used by onSuccessTaskId and onFailureTaskId to chain tasks. |
name | string | id | Display name shown in the TUI board. |
command | string | "" | Shell command to execute. |
commandArgs | string[] | [] | Arguments for command, passed as an array. |
commandRaw | string | — | Raw command string (alternative to command + commandArgs). |
onSuccessTaskId | string | null | null | Task ID to run on success (exit code 0). |
onFailureTaskId | string | null | null | Task ID to run on failure (non-zero exit code). |
silentChain | boolean | false | Suppress this task's output from the chain context (useful for error handlers that should not pollute downstream context). |
context | object | — | Static context merged into the shared context when this task runs. |
steps | array | — | Multi-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
- The loop starts with
list-issues(referenced bytaskIdin the loop entry). list-issuesrunsgh issue listand outputs a JSON object with keys likeoldestNumber,oldestTitle,oldestCreatedAt, andtotal.- loop-task parses that JSON into the shared context.
- On success,
print-oldestruns. It uses{{oldestNumber}},{{oldestTitle}}, etc. — these are replaced with the values from the context. - If
list-issuesfails (non-zero exit), the chain stops becauseonFailureTaskIdisnull.
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: nullRuntime 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
| Action | Effect |
|---|---|
Create a .yaml file in .loops/recipes/ | Loop is created and scheduled immediately. |
Edit a .yaml file | Loop is reloaded in place. Existing run history is preserved. Task IDs are remapped to new random IDs. |
Delete a .yaml file | Loop 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
| Property | Normal loop | Recipe loop |
|---|---|---|
| Created via | loop-task new, TUI, or HTTP API | .loops/recipes/*.yaml file |
| Stored in | loops.json (daemon state) | .loops/recipes/ (your repo) |
| Deletable | Yes, via loop-task rm or API | No — remove the file instead |
| Version controlled | Only via loop-task export | Yes — lives in your repository |
| Hot-reloaded | No (requires restart or re-import) | Yes (file watcher) |
| Runtime overrides | Written to daemon state | Written back to the YAML file |
| Appears in status / TUI | Yes | Yes (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
intervalHumaninstead of raw milliseconds for readability. Both work, but30mis clearer than1800000. - Start with
manualinterval. SetintervalHuman: "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. AddmaxRuns: 1during 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.