Skip to content

Build a review loop

This guide builds a custom graph that sends one coding task through a worker and two independent reviewers. When either reviewer rejects the change, the loop runs the same worker again with both reviewers' feedback. The run succeeds when both accept, and fails after four passes.

The built-in software-change template already does this with more recovery paths. Build the loop yourself when you want to change the reviewers, their instructions, or the bounds.

Before you start

This guide assumes you have completed Run a first task: Zeroshot is installed, the Codex CLI is signed in, and you have a Git repository with an HTTP service to change.

The worker edits the current worktree

Commit or stash work you want to protect, and run the commands from the repository you intend to change.

Files

The example has three files. Download them or copy them from the sections below, and keep them outside the repository you are changing, for example in ~/review-loop/, so they stay out of the change.

File Purpose
input.json The task
review-loop.graph.json Worker, reviewers, loop, and terminal result
review-loop.runtime.json Harness, provider, models, and session scopes

The graph controls order and data flow. The runtime plan binds only the three nodes that run agents.

The excerpts in steps 1 to 4 leave out fields; the complete graph is in step 5.

1. Define input and state

A graph starts with a profile, an initial input type, and a policy. The caller supplies only the task:

"initialInput": {
  "kind": "record",
  "fields": {
    "task": { "type": { "kind": "string" }, "required": true }
  }
}

The root seq declares a state record with task plus one feedback field per reviewer, acceptanceFeedback and codeFeedback. The root may add required fields that have an implicit empty value, so both feedback strings start as "" without appearing in input.json.

Every structural node (seq, loop, par, choice) declares its own state type, so the graph repeats this record. Input bindings copy state into an agent's input. Promoted state paths copy updated state from a group back to the group that encloses it.

2. Add the worker

The worker is a step. It reads the task and both feedback fields:

{
  "kind": "step",
  "name": "worker",
  "worker": "builtin.agent.software-worker@1",
  "instructions": "Implement the requested change. If reviewer feedback is present, address both diagnostics before returning. Work in the shared repository and run focused checks.",
  "output": { "kind": "null" },
  "writeBindings": [],
  "attempts": 1
}

The full node also declares an input record and three inputBindings, one per field. Its output is null because the worker changes files in the shared workspace rather than returning data.

The worker value is a label local to this graph. The builtin. names only mirror the built-in template; they add no behavior. Admission derives each worker's contract from the node's declared input, output, signals, and diagnostic, so a label used on several nodes must keep the same declaration and binding kind. Every agent node needs authored instructions.

3. Run two reviewers in parallel

A par group named reviews runs two verifiers against the same workspace. Each verifier returns a verdict signal and a diagnostic, and writes the diagnostic message into its feedback field:

{
  "kind": "verifier",
  "name": "acceptance",
  "worker": "builtin.agent.acceptance-verifier@1",
  "signals": { "verdict": ["accepted", "rejected"] },
  "writeBindings": [
    {
      "value": { "node": "acceptance", "channel": "diagnostic", "path": ["message"] },
      "target": ["acceptanceFeedback"]
    }
  ]
}

The code verifier has the same shape and writes codeFeedback. Zeroshot tells verifiers not to edit the material under review; that is an instruction, not a filesystem boundary.

The all join waits for both reviewers. reviews promotes both feedback paths into the iteration state, and the iteration promotes them into the loop state, so the next worker pass can read them.

4. Stop on two acceptances

The loop body is a seq of the worker and reviews. Loops are do-while: until is checked after each pass, so the worker always runs at least once.

"until": {
  "kind": "all",
  "guards": [
    {
      "kind": "in",
      "value": { "name": "acceptance", "source": "signal", "field": "verdict" },
      "labels": ["accepted"]
    },
    {
      "kind": "in",
      "value": { "name": "code", "source": "signal", "field": "verdict" },
      "labels": ["accepted"]
    }
  ]
},
"maxIterations": 4

A loop reports converged or exhausted through its terminated group control. A choice after the loop turns that control into an explicit terminal:

{
  "kind": "choice",
  "name": "loop_result",
  "branches": [
    {
      "when": {
        "kind": "in",
        "value": { "name": "review_loop", "source": "group", "field": "terminated" },
        "labels": ["converged"]
      },
      "node": { "kind": "succeed", "name": "done", "output": { "kind": "null" }, "bindings": [] }
    }
  ],
  "otherwise": {
    "kind": "fail",
    "name": "review_attempts_exhausted",
    "reason": "review_attempts_exhausted"
  }
}

5. The complete graph

review-loop.graph.json
{
  "profile": "openengine.graph.full/v1",
  "initialInput": {
    "kind": "record",
    "fields": {
      "task": {
        "type": {
          "kind": "string"
        },
        "required": true
      }
    }
  },
  "policy": {
    "policy": "policy.native-v2@1",
    "default": "deny"
  },
  "root": {
    "kind": "seq",
    "name": "run",
    "state": {
      "kind": "record",
      "fields": {
        "acceptanceFeedback": {
          "type": {
            "kind": "string"
          },
          "required": true
        },
        "codeFeedback": {
          "type": {
            "kind": "string"
          },
          "required": true
        },
        "task": {
          "type": {
            "kind": "string"
          },
          "required": true
        }
      }
    },
    "children": [
      {
        "kind": "loop",
        "name": "review_loop",
        "state": {
          "kind": "record",
          "fields": {
            "acceptanceFeedback": {
              "type": {
                "kind": "string"
              },
              "required": true
            },
            "codeFeedback": {
              "type": {
                "kind": "string"
              },
              "required": true
            },
            "task": {
              "type": {
                "kind": "string"
              },
              "required": true
            }
          }
        },
        "body": {
          "kind": "seq",
          "name": "review_iteration",
          "state": {
            "kind": "record",
            "fields": {
              "acceptanceFeedback": {
                "type": {
                  "kind": "string"
                },
                "required": true
              },
              "codeFeedback": {
                "type": {
                  "kind": "string"
                },
                "required": true
              },
              "task": {
                "type": {
                  "kind": "string"
                },
                "required": true
              }
            }
          },
          "children": [
            {
              "kind": "step",
              "name": "worker",
              "worker": "builtin.agent.software-worker@1",
              "instructions": "Implement the requested change. If reviewer feedback is present, address both diagnostics before returning. Work in the shared repository and run focused checks.",
              "input": {
                "kind": "record",
                "fields": {
                  "acceptanceFeedback": {
                    "type": {
                      "kind": "string"
                    },
                    "required": true
                  },
                  "codeFeedback": {
                    "type": {
                      "kind": "string"
                    },
                    "required": true
                  },
                  "task": {
                    "type": {
                      "kind": "string"
                    },
                    "required": true
                  }
                }
              },
              "output": {
                "kind": "null"
              },
              "inputBindings": [
                {
                  "target": ["task"],
                  "value": {
                    "source": "state",
                    "path": ["task"]
                  }
                },
                {
                  "target": ["acceptanceFeedback"],
                  "value": {
                    "source": "state",
                    "path": ["acceptanceFeedback"]
                  }
                },
                {
                  "target": ["codeFeedback"],
                  "value": {
                    "source": "state",
                    "path": ["codeFeedback"]
                  }
                }
              ],
              "writeBindings": [],
              "attempts": 1
            },
            {
              "kind": "par",
              "name": "reviews",
              "state": {
                "kind": "record",
                "fields": {
                  "acceptanceFeedback": {
                    "type": {
                      "kind": "string"
                    },
                    "required": true
                  },
                  "codeFeedback": {
                    "type": {
                      "kind": "string"
                    },
                    "required": true
                  },
                  "task": {
                    "type": {
                      "kind": "string"
                    },
                    "required": true
                  }
                }
              },
              "branches": [
                {
                  "kind": "verifier",
                  "name": "acceptance",
                  "worker": "builtin.agent.acceptance-verifier@1",
                  "input": {
                    "kind": "record",
                    "fields": {
                      "task": {
                        "type": {
                          "kind": "string"
                        },
                        "required": true
                      }
                    }
                  },
                  "output": {
                    "kind": "null"
                  },
                  "inputBindings": [
                    {
                      "target": ["task"],
                      "value": {
                        "source": "state",
                        "path": ["task"]
                      }
                    }
                  ],
                  "writeBindings": [
                    {
                      "value": {
                        "node": "acceptance",
                        "channel": "diagnostic",
                        "path": ["message"]
                      },
                      "target": ["acceptanceFeedback"]
                    }
                  ],
                  "attempts": 1,
                  "signals": {
                    "verdict": ["accepted", "rejected"]
                  },
                  "diagnostic": {
                    "kind": "record",
                    "fields": {
                      "message": {
                        "type": {
                          "kind": "string"
                        },
                        "required": true
                      }
                    }
                  },
                  "instructions": "Check the implementation against the requested behavior. Do not edit files. Accept only with concrete evidence; otherwise return actionable feedback."
                },
                {
                  "kind": "verifier",
                  "name": "code",
                  "worker": "builtin.agent.code-verifier@1",
                  "input": {
                    "kind": "record",
                    "fields": {
                      "task": {
                        "type": {
                          "kind": "string"
                        },
                        "required": true
                      }
                    }
                  },
                  "output": {
                    "kind": "null"
                  },
                  "inputBindings": [
                    {
                      "target": ["task"],
                      "value": {
                        "source": "state",
                        "path": ["task"]
                      }
                    }
                  ],
                  "writeBindings": [
                    {
                      "value": {
                        "node": "code",
                        "channel": "diagnostic",
                        "path": ["message"]
                      },
                      "target": ["codeFeedback"]
                    }
                  ],
                  "attempts": 1,
                  "signals": {
                    "verdict": ["accepted", "rejected"]
                  },
                  "diagnostic": {
                    "kind": "record",
                    "fields": {
                      "message": {
                        "type": {
                          "kind": "string"
                        },
                        "required": true
                      }
                    }
                  },
                  "instructions": "Review correctness, safety, integration, and maintainability. Do not edit files. Return actionable feedback when rejecting."
                }
              ],
              "promotedStatePaths": [["acceptanceFeedback"], ["codeFeedback"]],
              "join": {
                "kind": "all"
              }
            }
          ],
          "promotedStatePaths": [["acceptanceFeedback"], ["codeFeedback"]]
        },
        "until": {
          "kind": "all",
          "guards": [
            {
              "kind": "in",
              "value": {
                "name": "acceptance",
                "source": "signal",
                "field": "verdict"
              },
              "labels": ["accepted"]
            },
            {
              "kind": "in",
              "value": {
                "name": "code",
                "source": "signal",
                "field": "verdict"
              },
              "labels": ["accepted"]
            }
          ]
        },
        "maxIterations": 4,
        "promotedStatePaths": []
      },
      {
        "kind": "choice",
        "name": "loop_result",
        "state": {
          "kind": "record",
          "fields": {
            "acceptanceFeedback": {
              "type": {
                "kind": "string"
              },
              "required": true
            },
            "codeFeedback": {
              "type": {
                "kind": "string"
              },
              "required": true
            },
            "task": {
              "type": {
                "kind": "string"
              },
              "required": true
            }
          }
        },
        "branches": [
          {
            "when": {
              "kind": "in",
              "value": {
                "name": "review_loop",
                "source": "group",
                "field": "terminated"
              },
              "labels": ["converged"]
            },
            "node": {
              "kind": "succeed",
              "name": "done",
              "output": {
                "kind": "null"
              },
              "bindings": []
            }
          }
        ],
        "otherwise": {
          "kind": "fail",
          "name": "review_attempts_exhausted",
          "reason": "review_attempts_exhausted"
        },
        "promotedStatePaths": []
      }
    ],
    "promotedStatePaths": []
  }
}

The structural nodes run, review_loop, review_iteration, reviews, and loop_result do not run agents, so the runtime plan has no entries for them.

6. Bind the agents

The runtime plan has one binding per executable node, keyed by node name: worker, acceptance, and code.

review-loop.runtime.json
{
  "harness": "codex",
  "provider": "openai",
  "size": "medium",
  "nodes": {
    "worker": {
      "kind": "agent",
      "model": "YOUR_MODEL_ID",
      "sessionScope": "node_instance"
    },
    "acceptance": {
      "kind": "agent",
      "model": "YOUR_MODEL_ID",
      "sessionScope": "execution"
    },
    "code": {
      "kind": "agent",
      "model": "YOUR_MODEL_ID",
      "sessionScope": "execution"
    }
  }
}

The worker uses node_instance, so each loop pass continues the same provider conversation. The reviewers use execution, so each review starts a fresh session and does not see the previous pass. The graph state still carries the feedback explicitly either way.

Replace YOUR_MODEL_ID with a model the provider accepts. Nodes can use different models or effort values; harness, provider, and size apply to the whole run. The bindings omit connections, so a local Codex run reuses the Codex CLI's login, and a Docker or cloud target derives the canonical OPENAI_API_KEY requirement. See the RuntimePlan reference for every field.

7. Validate, then run

The input supplies only the task:

input.json
{
  "task": "Add a health-check endpoint that returns HTTP 200. Add focused tests and keep the change scoped."
}

From the repository you want to change, check all three files without starting a run. Validation covers JSON shape, graph data flow, loop termination, runtime coverage, and the initial input:

zeroshot run \
  --title "Review loop validation" \
  --graph ~/review-loop/review-loop.graph.json \
  --runtime-config ~/review-loop/review-loop.runtime.json \
  --input ~/review-loop/input.json \
  --validate-only

Successful validation writes {"valid":true}.

Remove --validate-only to start the run:

zeroshot run \
  --title "Health-check endpoint" \
  --graph ~/review-loop/review-loop.graph.json \
  --runtime-config ~/review-loop/review-loop.runtime.json \
  --input ~/review-loop/input.json

Observe and control runs covers status, logs, and stopping.

The same files run on a target with --target cloud (see Connect Zeroshot Cloud) or the name of a direct target. A target checks out the worktree's pushed upstream branch, not local uncommitted changes, and resolves OPENAI_API_KEY from the submission environment or its connection store. The graph has no delivery node, so a target run does not push the accepted change. Add a Git delivery verifier after the loop, or use the software-change template with --push, --pr, or --ship, when the result must leave the target.