Build a Maker-Checker Agent Loop
Build a two-agent loop where a Maker generates code and a Checker critiques it.
Introduction
30 Second Summary
Think about how code review works on a team. One person writes the code, another person reviews it, and the back-and-forth catches mistakes that the original author would never spot alone.
In this project, you will build a Maker-Checker agent loop in Python using OpenRouter's free models. One agent generates code, the other critiques it with specific feedback, and the loop runs until the code passes review.
What You'll Build
You will run a single command and watch loop engineering in action as two AI agents debate your coding task in real time, printing labeled [MAKER] and [CHECKER] output to your terminal until the code passes review.
By the end of this project, you'll have:
- Point the loop at any coding task and watch two agents debate back and forth until the code passes review.
- Visible agent-to-agent communication where the Checker's critique flows back to the Maker, driving improvements round over round.
- A persistent STATE.md file that records every round of the conversation as durable state for auditing and debugging.
- Secret Mission: Add a third Summarizer agent that reads the completed STATE.md and generates a concise post-loop report.
Are there any prerequisites?
You should be comfortable with basic Python (functions, loops, dictionaries) and know how to run Python scripts from the terminal.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up OpenRouter and Verify the Connection
Before your AI agents can talk to each other, they need a connection to a language model.
OpenRouter is a unified API that gives you access to dozens of models, including free ones. In this step, you'll install the OpenRouter Python SDK, set your API key as an environment variable, and make a test call to prove the pipeline is live.
In this step, get ready to:
- Install the OpenRouter Python SDK.
- Set your API key as an environment variable.
- Create and run a test script to verify the connection.
Create your project folder and install the SDK
You need a dedicated folder to keep your project files organized. You also need the OpenRouter Python SDK so your scripts can communicate with language models.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir maker-checker-loop
cd maker-checker-loop
- Confirm you're inside the new folder by running:
pwd
You should see a path ending in maker-checker-loop.
- Install the OpenRouter Python SDK by running:
pip install openrouter==1.1.74
What does this command do?
pip install downloads and installs a Python package from PyPI.
openrouter==1.1.74 pins the exact SDK version so your code matches this project. The SDK requires Python 3.10 or higher.
You should see output ending with a line that includes Successfully installed.
Seeing 'command not found' for pip?
- Try pip3 instead of pip. Some systems use pip3 for Python 3.
- If that also fails, try running python3 -m pip install openrouter==1.1.74 to use the pip module directly.
Why can't my terminal find pip?
Set your API key as an environment variable
The OpenRouter SDK reads your API key from an environment variable called OPENROUTER_API_KEY. Setting it as an environment variable keeps the key out of your code.
- Set the environment variable by running the command for your operating system:
macOS
- In your terminal, run this command, replacing your-api-key-here with your actual OpenRouter API key:
export OPENROUTER_API_KEY="your-api-key-here"
Linux users can run this same command.
Windows
- In PowerShell, run this command, replacing your-api-key-here with your actual OpenRouter API key:
$env:OPENROUTER_API_KEY = "your-api-key-here"
Where do I find my API key?
You can find and manage your OpenRouter API keys at openrouter.ai/settings/keys.
This environment variable only lasts for the current terminal session. If you close the terminal, you will need to set it again.
Create and run the test script
With the SDK installed and your key set, a quick test call proves the entire pipeline is working before you build anything more complex.
- Create a new file called test_connection.py in your maker-checker-loop folder.
- Paste the following code into it:
import os
from openrouter import OpenRouter
# Initialize the OpenRouter client using the API key from the environment
with OpenRouter(api_key=os.getenv("OPENROUTER_API_KEY", "")) as client:
# Send a simple message to the free Nemotron model
response = client.chat.send(
model="nvidia/nemotron-3-ultra-550b-a55b:free",
messages=[
{
"role": "user",
"content": "Say 'Loop engineering activated!' and nothing else.",
},
],
stream=False,
)
# Print the model's response
print(response.choices[0].message.content)
What does this code do?
- OpenRouter(api_key=os.getenv("OPENROUTER_API_KEY", "")) creates a client that reads your API key from the environment variable you just set.
- client.chat.send() sends a message to the nvidia/nemotron-3-ultra-550b-a55b:free model. This is a free model with a 1M token context window.
- stream=False tells the SDK to wait for the full response instead of streaming it back piece by piece.
- response.choices[0].message.content extracts the text of the model's reply.
- Save test_connection.py.
Before you run this, what do you think the model will respond with? Take a guess, then run it to find out.
- Run the test script by running:
python test_connection.py
You should see something like "Loop engineering activated!" printed in your terminal. The exact wording may vary slightly since the model generates its own response.
Seeing an authentication error?
- Double-check that your OPENROUTER_API_KEY is set correctly. Run echo $OPENROUTER_API_KEY (macOS/Linux) or echo $env:OPENROUTER_API_KEY (Windows PowerShell) to see if a value is printed.
- If no value appears, re-run the export command from the previous substep.
- If your terminal cannot find python, try python3 test_connection.py instead.
Why is my OpenRouter test script failing?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Here is the complete test_connection.py file. Compare it against yours line by line.
import os
from openrouter import OpenRouter
with OpenRouter(api_key=os.getenv("OPENROUTER_API_KEY", "")) as client:
response = client.chat.send(
model="nvidia/nemotron-3-ultra-550b-a55b:free",
messages=[
{
"role": "user",
"content": "Say 'Loop engineering activated!' and nothing else.",
},
],
stream=False,
)
print(response.choices[0].message.content)
Your connection to OpenRouter is live. Next up, you'll build the Maker agent and watch it generate code on its own.
Build the Maker Agent and Generate Code
Your OpenRouter connection is live. Now it's time to build the first agent in your loop.
Before you can orchestrate a multi-agent loop, you need a single agent that works on its own. The Maker agent's job is to generate Python code for any task you give it. By the end of this step, you'll see AI-generated code appear in your terminal, labeled so you know exactly which agent produced it.
In this step, get ready to:
- Create loop.py with a system prompt that tells the Maker how to behave.
- Build a reusable call_agent() function that sends messages to any agent and prints labeled output.
- Run the Maker on a coding task and see AI-generated code printed under a [MAKER] label.
Create loop.py with the Maker system prompt
Every agent needs instructions that define its personality and constraints. In loop engineering, these instructions live in a system prompt. The Maker's system prompt tells it to output only code inside a fenced code block, with no extra explanations.
- Create a new file called loop.py in the same folder as your test_connection.py file.
- Add the imports, system prompt, and model constant by pasting this code:
import os
from openrouter import OpenRouter
# System prompt that instructs the Maker to output only code
MAKER_SYSTEM_PROMPT = (
"You are a Python code generator. When given a coding task, "
"write clean, working Python code. Output ONLY the Python code "
"in a single fenced code block. No explanations outside the code block. "
"When you receive feedback from a reviewer, carefully address "
"each point and provide an improved version."
)
# Free model from OpenRouter
MODEL = "nvidia/nemotron-3-ultra-550b-a55b:free"
What does this code do?
MAKER_SYSTEM_PROMPT is a constant string that acts as the Maker agent's rulebook. It forces the model to output only a code block, keeping responses clean and parseable.
MODEL stores the model slug for NVIDIA's free Nemotron 3 Ultra. Defining it as a constant means you only change it in one place if you ever want to swap models.
- Save loop.py.
- Confirm the file was created by running:
ls
You should see both loop.py and test_connection.py listed in the output.
Build the call_agent() function
You need a function that can send messages to any agent role, not just the Maker. The call_agent() function takes a role name and system prompt, calls OpenRouter, and prints the response with a labeled header so you always know which agent is talking.
- In loop.py, add the call_agent() function below the MODEL constant by pasting this code:
def call_agent(client, role, system_prompt, messages):
"""Send messages to an agent and return the response content."""
# Prepend the system prompt to the conversation
full_messages = [{"role": "system", "content": system_prompt}] + messages
response = client.chat.send(
model=MODEL,
messages=full_messages,
stream=False,
temperature=0.7,
)
reply = response.choices[0].message.content
# Print a labeled header so you can tell which agent is speaking
print(f"\n{'=' * 60}")
print(f"[{role}]:")
print(f"{'=' * 60}")
print(reply)
return reply
What does this code do?
full_messages prepends the system prompt as the first message in the conversation. This is how you give the model its role and constraints before it sees the user's request.
client.chat.send() calls OpenRouter with stream=False so you get the full response at once. The temperature=0.7 adds a small amount of creativity to code generation.
The print() lines create a visual separator with the agent's role name (like [MAKER]), so when multiple agents talk later, you can instantly tell who said what.
- Save loop.py. The function is ready to use. In the next chunk, you'll call it and see the Maker in action.
Wire up the main block and run the Maker
The Maker needs a coding task to work on. You'll give it a realistic one: writing a function that safely parses a CSV file with proper error handling. This is a meaty enough task to produce interesting code.
- At the bottom of loop.py, add the __main__ block by pasting this code:
if __name__ == "__main__":
# Define the coding task for the Maker
task = (
"Write a function called 'parse_csv_safely' that reads a CSV file path "
"and returns a list of dictionaries. Handle file not found, empty files, "
"malformed rows, and encoding errors gracefully."
)
# Initialize the OpenRouter client and send the task to the Maker
with OpenRouter(api_key=os.getenv("OPENROUTER_API_KEY", "")) as client:
maker_messages = [
{
"role": "user",
"content": f"Write Python code for this task: {task}",
}
]
call_agent(client, "MAKER", MAKER_SYSTEM_PROMPT, maker_messages)
What does this code do?
task holds the coding challenge you want the Maker to solve. It describes a parse_csv_safely function with four specific error-handling requirements, giving the model a clear target.
The with OpenRouter(...) block creates a client connection (the same pattern you used in test_connection.py), then builds a message list with the task and passes it to call_agent().
- Save loop.py.
Before you run this, what do you think the Maker will output? Will it respond with a conversational explanation, or something else?
- Run the Maker by executing:
python loop.py
You should see a [MAKER] label appear, followed by a block of Python code inside a fenced code block. The system prompt worked. The Maker generated only code, no extra chatter.
Why does the output look like raw code?
The MAKER_SYSTEM_PROMPT told the model to output only code in a fenced code block. Without that constraint, the model would wrap its code in paragraphs of explanation. Keeping agent output structured and predictable is essential for loop engineering, because a downstream agent (like the Checker you'll build next) needs to parse and evaluate what the Maker produces.
Not seeing any output?
- Make sure your OPENROUTER_API_KEY environment variable is still set. If you opened a new terminal, you need to re-export it.
- Check that you saved loop.py after adding all three sections (imports, function, and main block).
- Free models on OpenRouter have a limit of 50 requests per day. If you've been experimenting, you may have hit the cap.
- help me debug why my loop.py isn't producing output
✔️ Awesome, I've got everything!
Great. Double check you've saved loop.py with all three sections: the imports and constants at the top, the call_agent() function in the middle, and the __main__ block at the bottom.
ⓧ I'd like to double check the full code
Scroll up and confirm your loop.py contains all three chunks in this order:
- The imports (os and OpenRouter), MAKER_SYSTEM_PROMPT, and MODEL constant at the top.
- The call_agent() function with the labeled print statements.
- The if __name__ == "__main__": block that defines the task and calls call_agent().
You've got a working Maker agent that generates code on command. But here's the question: can you trust the Maker to judge its own work? Next up, you'll find out what happens when the Maker reviews itself.
The Self-Review Trap
Your Maker agent is generating code on demand. In the last step, you watched it produce a full Python function from a single prompt.
But can it also check its own work? If a single agent can both write and honestly critique code, you wouldn't need anything more complex. Let's test that assumption.
In this step, get ready to:
- Add a self-review step that feeds the Maker's code back to itself for critique.
- Run the script and discover whether the Maker can honestly evaluate its own output.
Add a self-review step to the Maker
The experiment is straightforward. Take the Maker's generated code and pass it right back to the same agent, asking it to review for correctness, edge cases, and best practices.
- In loop.py, scroll down to the last line inside your with OpenRouter block.
- Find this line:
call_agent(client, "MAKER", MAKER_SYSTEM_PROMPT, maker_messages)
- Add maker_output = to the front of that line, so it captures the return value. The line should now look like this:
maker_output = call_agent(client, "MAKER", MAKER_SYSTEM_PROMPT, maker_messages)
- Directly below that line, add the self-review code that passes the Maker's output back to itself:
# Pass the Maker's code back to itself for review
self_review_messages = [
{
"role": "user",
"content": (
f"Here is your code:\n\n{maker_output}\n\n"
"Review it for correctness, edge cases, and best practices. "
"Start your response with APPROVED or REJECTED."
),
}
]
call_agent(client, "MAKER", MAKER_SYSTEM_PROMPT, self_review_messages)
What does this code do?
- self_review_messages packages the Maker's generated code into a new prompt that asks for a review.
- The second call_agent() sends that review request to the exact same agent with the same MAKER_SYSTEM_PROMPT. The Maker is grading its own homework.
- Save loop.py.
Run the script and observe the self-review
Before you run this, take a guess. Will the Maker find real problems in its own code, or will it go easy on itself?
- Run the updated script by running this command:
python loop.py
You'll see two [MAKER] blocks print in your terminal. The first generates the code. The second is the self-review.
Look at the verdict. The Maker almost always prints APPROVED. It might mention minor improvements, but it won't seriously critique its own work. This is the "rubber stamp" problem.
Why does the Maker approve its own code?
The model that wrote the code is too generous grading its own work. A second agent with different instructions catches the stuff the first one talked itself into.
A single agent cannot be both the creator and the honest critic. You need a separate evaluator with adversarial instructions that is not influenced by having written the code. This is the core problem that loop engineering solves.
Seeing an authentication error?
- Make sure your OPENROUTER_API_KEY environment variable is still set in the same terminal session. If you opened a new terminal, you need to export it again.
- Free accounts have a limit of 50 requests per day. If you've been experimenting, you may have hit the cap. Wait until the next day or check your usage at openrouter.ai.
Why is my OpenRouter API call failing with an authentication error?
✔️ Awesome, I've got everything!
Great. Double check you've saved loop.py.
ⓧ I'd like to double check the full code
Here is the full loop.py at this point. Scroll through and confirm your file matches:
import os
from openrouter import OpenRouter
MAKER_SYSTEM_PROMPT = (
"You are a Python code generator. When given a coding task, "
"write clean, working Python code. Output ONLY the Python code "
"in a single fenced code block. No explanations outside the code block. "
"When you receive feedback from a reviewer, carefully address "
"each point and provide an improved version."
)
MODEL = "nvidia/nemotron-3-ultra-550b-a55b:free"
def call_agent(client, role, system_prompt, messages):
"""Send messages to an agent and return the response content."""
full_messages = [{"role": "system", "content": system_prompt}] + messages
response = client.chat.send(
model=MODEL,
messages=full_messages,
stream=False,
temperature=0.7,
)
reply = response.choices[0].message.content
print(f"\n{'=' * 60}")
print(f"[{role}]:")
print(f"{'=' * 60}")
print(reply)
return reply
if __name__ == "__main__":
task = (
"Write a function called 'parse_csv_safely' that reads a CSV file path "
"and returns a list of dictionaries. Handle file not found, empty files, "
"malformed rows, and encoding errors gracefully."
)
with OpenRouter(api_key=os.getenv("OPENROUTER_API_KEY", "")) as client:
maker_messages = [
{
"role": "user",
"content": f"Write Python code for this task: {task}",
}
]
maker_output = call_agent(client, "MAKER", MAKER_SYSTEM_PROMPT, maker_messages)
self_review_messages = [
{
"role": "user",
"content": (
f"Here is your code:\n\n{maker_output}\n\n"
"Review it for correctness, edge cases, and best practices. "
"Start your response with APPROVED or REJECTED."
),
}
]
call_agent(client, "MAKER", MAKER_SYSTEM_PROMPT, self_review_messages)
The rubber stamp is real. A single agent is too generous with its own work. In the next step, you'll build a separate Checker agent with adversarial instructions that catches what the Maker misses.
Build the Checker and Wire Agent Communication
In Step 3, you saw the rubber-stamp problem firsthand. The Maker reviewed its own code and predictably approved it without serious critique.
The fix is to separate the generator from the evaluator. In this step, you will introduce a second agent with adversarial instructions and wire the two into a multi-round loop. The Checker's critique flows back to the Maker, the Maker revises, and the cycle repeats until the code passes. This is the heart of loop engineering.
In this step, get ready to:
- Add a Checker system prompt with strict, adversarial review instructions.
- Build a run_loop() function that orchestrates multi-round Maker-Checker communication.
- Run the full loop and watch two agents debate until the code passes review.
Add the Checker's adversarial system prompt
The Checker needs fundamentally different instructions from the Maker. While the Maker is told to generate code, the Checker is told to be strict and adversarial. The key design constraint: it must start its response with "APPROVED" or "REJECTED", giving the loop a clear programmatic signal to stop or continue.
- In loop.py, find the line MAKER_SYSTEM_PROMPT = ( and scroll past its closing ).
- Add the Checker's system prompt below it, before the MODEL line, by pasting the following code:
CHECKER_SYSTEM_PROMPT = (
"You are a strict, adversarial code reviewer. You must independently "
"evaluate the code without being influenced by the author's confidence. "
"Review for:\n"
"1. Correctness - does it handle edge cases?\n"
"2. Readability - clear naming, good structure?\n"
"3. Best practices - type hints, docstrings, error handling?\n"
"4. Security - any unsafe patterns?\n\n"
"If the code meets ALL criteria, respond with exactly 'APPROVED' as the "
"first word, followed by a brief summary of why it passes.\n"
"If it needs improvement, respond with 'REJECTED' as the first word, "
"followed by specific, actionable feedback for each issue found."
)
What does this prompt do?
The Checker reviews on four criteria: correctness, readability, best practices, and security. It is explicitly told to be independent and not influenced by the author's confidence.
The critical detail is the response format. "APPROVED" or "REJECTED" as the first word gives your code a simple startswith("APPROVED") check to decide whether the loop should continue or stop.
- Save loop.py.
- Confirm the new prompt loads without errors by running:
python -c "from loop import CHECKER_SYSTEM_PROMPT; print('Checker prompt loaded')"
You should see Checker prompt loaded printed with no errors.
Seeing a SyntaxError?
Check that every opening parenthesis ( in CHECKER_SYSTEM_PROMPT has a matching closing ). Make sure the prompt is placed between MAKER_SYSTEM_PROMPT and MODEL, with a blank line separating each.
help me fix a SyntaxError in my loop.py after adding CHECKER_SYSTEM_PROMPT
Replace the self-review with a Maker-Checker loop
The self-review code in your current __main__ block proved that a single agent rubber-stamps its own work. Now you will replace it with a run_loop() function that orchestrates real agent-to-agent communication.
- In loop.py, select and delete everything from if __name__ == "__main__": through the end of the file.
- In the empty space below call_agent(), start building the run_loop() function by pasting this code:
def run_loop(task, max_rounds=4):
"""Run the Maker-Checker loop until approved or max rounds reached."""
state = {
"task": task,
"status": "running",
"total_rounds": 0,
"rounds": [],
}
conversation = []
with OpenRouter(api_key=os.getenv("OPENROUTER_API_KEY", "")) as client:
print(f"\n{'#' * 60}")
print("LOOP ENGINEERING: Maker-Checker Code Review")
print(f"Task: {task}")
print(f"Max Rounds: {max_rounds}")
print(f"Model: {MODEL}")
print(f"{'#' * 60}")
for round_num in range(1, max_rounds + 1):
print(f"\n{'*' * 60}")
print(f"ROUND {round_num} of {max_rounds}")
print(f"{'*' * 60}")
What does this code do?
- state is a dictionary that tracks the loop's progress: the task, current status, total rounds, and a list of each round's data.
- conversation builds up the Maker's message history so it has context from prior rounds.
- The with OpenRouter(...) block creates a single client connection shared across all rounds.
- The for loop runs up to max_rounds times (defaulting to 4), printing a round header each time.
- Still inside the for round_num loop (indented to match the print statements above), add the Maker's message construction and call by pasting this code directly below:
if round_num == 1:
maker_messages = [
{
"role": "user",
"content": f"Write Python code for this task: {task}",
}
]
else:
last_feedback = state["rounds"][-1]["checker_feedback"]
maker_messages = conversation + [
{
"role": "user",
"content": (
"The reviewer rejected your code with this feedback:\n\n"
f"{last_feedback}\n\n"
"Please fix the issues and provide an improved version."
),
}
]
maker_output = call_agent(
client, "MAKER", MAKER_SYSTEM_PROMPT, maker_messages
)
conversation.append({"role": "assistant", "content": maker_output})
What does this code do?
- On round 1, the Maker gets a fresh prompt with just the coding task.
- On later rounds, the Maker receives its conversation history plus the Checker's rejection feedback, so it knows exactly what to fix.
- call_agent() sends the messages and prints the response under a [MAKER] label, then returns the text.
- Save loop.py and confirm no syntax errors by running:
python -c "from loop import run_loop; print('run_loop loaded')"
You should see run_loop loaded printed with no errors.
Seeing an IndentationError?
The if round_num == 1: block must be indented inside the for loop (three levels deep: function, with, for). Make sure you pasted it directly below the round header print statements at the same indentation level.
help me fix an IndentationError in my run_loop function
- Directly below the conversation.append line you just added, still inside the for loop, add the Checker call and approval logic by pasting this code:
checker_messages = [
{
"role": "user",
"content": (
f"Review this Python code written for the task: '{task}'\n\n"
f"{maker_output}"
),
}
]
checker_feedback = call_agent(
client, "CHECKER", CHECKER_SYSTEM_PROMPT, checker_messages
)
is_approved = checker_feedback.strip().upper().startswith("APPROVED")
result = "APPROVED" if is_approved else "REJECTED"
print(f"\n>>> Round {round_num} Result: {result}")
state["rounds"].append(
{
"maker_output": maker_output,
"checker_feedback": checker_feedback,
"result": result,
}
)
state["total_rounds"] = round_num
if is_approved:
state["status"] = "approved"
print(f"\n{'#' * 60}")
print(f"LOOP COMPLETE: Code approved after {round_num} round(s)!")
print(f"{'#' * 60}")
return state
What does this code do?
- The Checker receives the Maker's output and reviews it using CHECKER_SYSTEM_PROMPT.
- is_approved checks if the response starts with "APPROVED". This is the programmatic signal your Checker prompt was designed to produce.
- Each round's data (maker output, checker feedback, result) is appended to state["rounds"] for tracking.
- If approved, the function returns immediately and the loop stops.
- Directly below the return state line inside the if is_approved: block, finish the function and add a new __main__ block by pasting this code:
conversation.append(
{"role": "user", "content": f"Reviewer feedback: {checker_feedback}"}
)
state["status"] = "max_rounds_reached"
print(f"\n{'#' * 60}")
print(f"LOOP ENDED: Max rounds ({max_rounds}) reached without approval")
print(f"{'#' * 60}")
return state
if __name__ == "__main__":
task = (
"Write a function called 'parse_csv_safely' that reads a CSV file path "
"and returns a list of dictionaries. Handle file not found, empty files, "
"malformed rows, and encoding errors gracefully."
)
run_loop(task)
What does this code do?
- If the Checker rejected the code, the feedback is appended to the conversation list so the Maker sees it in the next round.
- After the for loop exhausts all rounds, the function returns with status max_rounds_reached. This safety cap prevents infinite loops.
- The new __main__ block calls run_loop(task) with the same coding task, replacing the old self-review code.
- Save loop.py.
Run the full loop and watch the agents debate
Everything is wired. Before you run the script, think about what you expect to see.
Do you think the Checker will approve the Maker's first attempt, or reject it and force revisions? How will this compare to the self-review in Step 3?
- Run the Maker-Checker loop by running:
python loop.py
You should see alternating [MAKER] and [CHECKER] output in your terminal across multiple rounds. The Checker will likely reject the first attempt with specific feedback, and the Maker's next attempt will directly address those critiques. The loop ends when the Checker responds with APPROVED or the 4-round safety cap is reached.
What should I notice?
Compare this to Step 3's self-review. The Maker rubber-stamped its own code. Now, the Checker with its adversarial instructions catches real issues: missing type hints, weak error handling, missing docstrings, or unsafe patterns.
Watch how the Maker's second attempt directly addresses the Checker's critique. Each round of agent-to-agent communication makes the code measurably better. This is why separating the generator from the evaluator is the core principle of loop engineering.
Seeing an API error or no output?
- If you see an authentication error, confirm your OPENROUTER_API_KEY is still set in your terminal session. Environment variables reset when you close the terminal.
- If you see a rate limit error, wait a minute and try again. Free OpenRouter accounts allow 20 requests per minute.
- If the output seems truncated or the Checker always approves on round 1, the model may be having an off day. Run the script again to see a different result.
help me debug my Maker-Checker loop that is not producing expected output
✔️ Awesome, I've got everything!
Great. Double check you have saved loop.py.
ⓧ I'd like to double check the full code
Your loop.py file is now too long to show in one block. Scroll up and confirm each section is present in this order:
- The import os and from openrouter import OpenRouter lines at the top.
- MAKER_SYSTEM_PROMPT constant.
- CHECKER_SYSTEM_PROMPT constant (the one you just added).
- MODEL constant set to nvidia/nemotron-3-ultra-550b-a55b:free.
- The call_agent() function (unchanged from Step 2).
- The run_loop() function containing state setup, the for loop with maker and checker logic, approval checking, and termination handling.
- The if __name__ == "__main__": block calling run_loop(task).
Your loop is running and two agents are communicating. But right now, all that conversation disappears when the script finishes. In the next step, you will add durable state so every round is recorded in a persistent file.
Add Durable State to STATE.md
Your Maker-Checker loop now produces real agent-to-agent conversation in your terminal. The Checker's feedback flows back to the Maker, the code improves round over round, and the loop self-terminates.
There is one problem: the moment the script ends, the entire conversation vanishes. If the script crashes mid-loop, every round of progress is gone. Durable state is what separates a throwaway script from a real loop engineering system. In this step, you will write every round to a persistent STATE.md file so the loop's memory survives restarts, crashes, and future sessions.
In this step, get ready to:
- Add a save_state() function that writes the loop's history to STATE.md.
- Wire save_state() into the loop so progress is saved after every round.
- Run the loop and verify that STATE.md contains the full round-by-round history.
Add the save_state() function
The loop already tracks its state in a Python dictionary (the state variable inside run_loop()). You need a function that takes that dictionary and writes it to disk as a readable markdown file.
- In loop.py, find the call_agent() function. Add the following new function directly below it, before run_loop():
def save_state(state):
"""Write the current loop state to STATE.md."""
with open("STATE.md", "w") as f:
f.write("# Maker-Checker Loop State\n\n")
f.write(f"Task: {state['task']}\n\n")
f.write(f"Status: {state['status']}\n\n")
f.write(f"Total Rounds: {state['total_rounds']}\n\n")
for i, round_data in enumerate(state["rounds"], 1):
f.write(f"## Round {i}\n\n")
f.write(f"### Maker Output\n\n{round_data['maker_output']}\n\n")
f.write(f"### Checker Feedback\n\n{round_data['checker_feedback']}\n\n")
f.write(f"Result: {round_data['result']}\n\n---\n\n")
What does this code do?
- open("STATE.md", "w") opens (or creates) the file in write mode. Each call overwrites the entire file with the latest state, so the file always reflects the full picture.
- The function writes the task description, current status, and total rounds as header metadata.
- The for loop iterates through every completed round and writes each one as its own markdown section with the Maker's output, the Checker's feedback, and the APPROVED/REJECTED result.
What is a spine?
In loop engineering, the persistent state file is often called the "spine." It tracks project progress and prevents error repetition across cycles. With each round, the agent adds outcomes to this spine so the loop can resume, audit, or debug from any point.
- Save loop.py.
Wire save_state() into the loop
The function exists, but nothing calls it yet. You need to call save_state() after every round so that progress is persisted even if the script crashes mid-loop.
- Inside the run_loop() function, find this line inside the for loop:
state["total_rounds"] = round_num
if is_approved:
- Add a save_state(state) call between those two lines, so it looks like this:
state["total_rounds"] = round_num
save_state(state)
if is_approved:
Why save after every round?
Calling save_state() inside the loop body (not just at the end) means that if the script is interrupted mid-run, every completed round is already on disk. The file acts as a checkpoint.
Next, update the two completion messages so the learner knows where to find the saved history.
- In the if is_approved: block, find this line:
print(f"LOOP COMPLETE: Code approved after {round_num} round(s)!")
print(f"{'#' * 60}")
- Add a new print line between them so the block looks like this:
print(f"LOOP COMPLETE: Code approved after {round_num} round(s)!")
print("Full history saved to STATE.md")
print(f"{'#' * 60}")
- Now find the max_rounds_reached block at the bottom of run_loop(). It currently looks like this:
state["status"] = "max_rounds_reached"
print(f"\n{'#' * 60}")
print(f"LOOP ENDED: Max rounds ({max_rounds}) reached without approval")
print(f"{'#' * 60}")
return state
- Replace that block with this version, which adds the same "Full history saved" message and a final save_state(state) call:
state["status"] = "max_rounds_reached"
print(f"\n{'#' * 60}")
print(f"LOOP ENDED: Max rounds ({max_rounds}) reached without approval")
print("Full history saved to STATE.md")
print(f"{'#' * 60}")
save_state(state)
return state
Why save again at the end?
The per-round save_state() call writes state while status is still "running". This final call at the bottom writes the file one last time after status is set to "max_rounds_reached", so the file reflects the true final outcome.
- Save loop.py.
Seeing a NameError for save_state?
- Make sure the save_state() function is defined above run_loop() in the file. Python reads top to bottom, so the function must exist before it is called.
- Check that the indentation of save_state(state) inside the for loop matches the surrounding lines. It should be at the same level as state["total_rounds"] = round_num.
Why is my save_state function not being found when the loop runs?
✔️ Awesome, I've got everything!
Great. Make sure you have saved loop.py before moving on.
ⓧ I'd like to double check the full code
Your loop.py is now too long to show in one block. Scroll up through your file and confirm these sections appear in this order:
- The import os and from openrouter import OpenRouter imports at the top.
- MAKER_SYSTEM_PROMPT, CHECKER_SYSTEM_PROMPT, and MODEL constants.
- The call_agent() function.
- The new save_state() function (between call_agent and run_loop).
- The run_loop() function, now with save_state(state) called after state["total_rounds"] = round_num, plus "Full history saved to STATE.md" messages in both completion blocks.
- The __main__ block at the bottom calling run_loop(task).
Run the loop and verify STATE.md
Before you run the script, think about what you expect to find on disk afterwards. The loop will run the same Maker-Checker conversation you saw in the previous step. What is different this time?
- Run the loop by executing this command:
python loop.py
You will see the same alternating [MAKER] and [CHECKER] output as before. This time, the final message also says "Full history saved to STATE.md".
- Confirm that STATE.md was created by running:
ls
You should see STATE.md listed alongside loop.py and test_connection.py.
- Open STATE.md in your text editor.
You should see a structured markdown file with a # Maker-Checker Loop State heading at the top, followed by the task description, status, and total rounds. Below that, each round has its own ## Round N section with the Maker's output, the Checker's feedback, and the APPROVED or REJECTED result.
STATE.md is empty or missing?
- Check that save_state(state) appears inside the for loop, directly after state["total_rounds"] = round_num. If it is placed outside the loop, it only runs once at the end.
- Make sure you are running python loop.py from the same folder that contains loop.py. The file is created in the current working directory.
- If the script crashed with an API error before completing any round, no rounds were saved. Check your OPENROUTER_API_KEY is still set and try again.
My STATE.md file is empty or was not created after running the loop. What should I check?
Your loop now has memory. Every round of the Maker-Checker conversation is recorded on disk, creating a persistent spine that survives restarts and crashes. You have a complete, reusable loop engineering system: discovery, handoff, execution, verification, and durable state.
Secret mission
Add a Summarizer Agent
Your Maker-Checker loop records raw data to STATE.md, but nobody reads raw logs. Add a third Summarizer agent that consumes the loop's state file and distills it into a concise REPORT.md, proving that durable state is not just a log but a resource other agents can build on.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using free OpenRouter models, so there are no ongoing costs. Decide whether to keep your files and setup for future experiments, pause to come back later, or delete everything.
Resources you used:
- test_connection.py (OpenRouter test script).
- loop.py (Maker-Checker loop script).
- STATE.md (generated loop history).
- REPORT.md (generated summary report).
- openrouter Python package (installed via pip).
- OPENROUTER_API_KEY environment variable.
Keep everything running
No action needed. Choose this if you want to keep experimenting with different coding tasks, tweaking the system prompts, or extending the loop with new agent roles.
- Your project files (loop.py, test_connection.py, STATE.md, REPORT.md) stay on disk.
- The openrouter package stays installed in your Python environment.
- The OPENROUTER_API_KEY environment variable is session-scoped. It disappears when you close your terminal. Re-export it when you return.
Pause - I'll come back to this later
Your files stay on disk and the openrouter package stays installed. There is nothing to shut down or pause because the project runs entirely locally with no background processes.
- The OPENROUTER_API_KEY environment variable was set with export, so it only lasts for the current terminal session. When you come back, re-export it before running the script.
Delete - I don't want to use this again
Remove all project files and uninstall the SDK to start fresh.
- Delete the project files by running this command in the folder where you created them.
macOS
rm test_connection.py loop.py STATE.md REPORT.md
Windows
del test_connection.py, loop.py, STATE.md, REPORT.md
- Uninstall the OpenRouter SDK by running:
pip uninstall openrouter
- Enter y when prompted to confirm the uninstall.
- Clear the API key from your current terminal session by running:
macOS
unset OPENROUTER_API_KEY
Windows
Remove-Item Env:OPENROUTER_API_KEY
What about my OpenRouter account?
Your OpenRouter account existed before this project and costs nothing to keep. You only used the free tier. No action is needed unless you want to revoke your API key, which you can do at openrouter.ai/settings/keys.
Nice Work!
Nice Work!
Nice work! You just built a fully functional Maker-Checker agent loop in Python where two AI agents communicate, critique each other's code, and iteratively improve it until it passes review.
You've learned how to:
- Build a Maker-Checker loop where two AI agents communicate through OpenRouter, with the Checker's feedback flowing back to the Maker as context to drive visible code improvements round over round.
- Experience the self-review trap firsthand, proving that a single agent rubber-stamps its own work and that loop engineering solves this by separating the generator from the evaluator.
- Add durable state tracking with STATE.md, creating the persistent "spine" that real loop engineering systems use for memory and auditing.
- Secret Mission: Composed a third Summarizer agent that reads the loop's state and produces a concise report saved to REPORT.md, demonstrating how agent roles can be composed on top of durable state.
Ready to quiz yourself?