Two AI Agents, One Blog Post: A Writer and Editor Loop in Python
I have lost count of how many first drafts I have written that needed a second pair of eyes. Usually those eyes are mine, a day later, wondering what past-me was thinking. So here is a small experiment: what if the second pair of eyes was a separate agent, one that does nothing but critique, and the writer kept revising until that critic was happy?
The writer-editor agent loop is a two-agent critique-and-revise pattern: one agent writes a technical post, a second agent reviews it and hands back detailed feedback, and the writer revises. Round and round, until the editor approves the draft, a human stops the loop, or we hit a sensible cap. Nothing exotic — just two roles, a feedback loop, and a paper trail.
This post walks through a self-contained Python implementation. The implementation runs on three different backends without changing a line of the agent logic: the Anthropic API, the OpenAI API, and a free, open-source Hugging Face model that fits on an M1 laptop. The full script is agents.py, and this post is really its companion.
If you want to skip ahead, the loop is: draft → review → (revise → review)* → stop. The rest is plumbing, and the plumbing is where the interesting decisions live.
Architecture: A Shared LLM Backend Interface for Writer and Editor Agents
Two agents, one shared interface, one orchestrator holding the loop together.
The writer and the editor are deliberately independent. They do not share memory, they do not see each other’s system prompts, and they only communicate through text that the orchestrator passes between them. That independence matters: it means the editor cannot quietly collude with the writer, and it means you can swap either agent’s model without touching the other.
LLMBackend is a minimal abstract interface that lets the writer and editor call any LLM provider — Anthropic, OpenAI, or a local Hugging Face model — through the same two methods, so swapping providers requires no change to the agent logic itself. A backend takes a system prompt and a user prompt and returns the model’s text and the token usage for that call. Bundling usage into the return value is what makes cost tracking painless later — the count comes back from the same call that produced the text, so nothing has to be re-derived.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Tuple
@dataclass
class Usage:
"""Token counts for a single model call."""
input_tokens: int = 0
output_tokens: int = 0
def __add__(self, other: "Usage") -> "Usage":
return Usage(
self.input_tokens + other.input_tokens,
self.output_tokens + other.output_tokens,
)
class LLMBackend(ABC):
"""A minimal, uniform interface every provider must satisfy."""
name: str = "base"
model: str = "base"
@abstractmethod
def generate(self, system: str, user: str, max_tokens: int = 2048) -> Tuple[str, Usage]:
"""Return (text, Usage) for a system + user prompt."""
raise NotImplementedError
Keep this small and the agents stop caring which model sits underneath. That is the point of the whole exercise — the agents describe behaviour, the backend supplies capability, and the two never leak into each other. The model attribute is the only extra: we need it to price the tokens.
Implementing the WriterAgent and EditorAgent Classes in Python
The WriterAgent has one job with two modes: draft from scratch, or revise against feedback. I keep its system prompt strict about one thing in particular — no placeholder code. Nothing sinks a tutorial faster than # implement logic here.
class WriterAgent:
"""Drafts the first version and revises it against editor feedback."""
def __init__(self, backend: LLMBackend):
self._backend = backend
def draft(self, topic: str) -> str:
user = f"Write a complete technical blog post on this topic:\n\n{topic}"
return self._backend.generate(WRITER_SYSTEM, user)
def revise(self, topic: str, draft: str, feedback: str) -> str:
user = (
f"Topic: {topic}\n\nHere is your current draft:\n---\n{draft}\n---\n\n"
"The editor returned this feedback. Address every point and return "
"the full revised post (not a diff, not a summary of changes):\n"
f"---\n{feedback}\n---\n"
)
return self._backend.generate(WRITER_SYSTEM, user)
The EditorAgent is where I spent most of my thinking time. A vague critic is worse than no critic — it just says “make it better” and everyone nods. So I force it to return a structured verdict I can parse reliably: either VERDICT: APPROVED or VERDICT: REVISE, followed by numbered, concrete feedback.
Parsing model output is a place where optimism gets punished, so the editor defaults to REVISE whenever it cannot find a clear verdict. If the model mumbles, we assume more work is needed. That asymmetry is deliberate: I would rather do one unnecessary revision than publish something a confused editor waved through.
import re
from dataclasses import dataclass
@dataclass
class Review:
approved: bool
feedback: str
raw: str
usage: Usage = field(default_factory=Usage)
class EditorAgent:
def __init__(self, backend: LLMBackend):
self._backend = backend
def review(self, topic: str, draft: str) -> Review:
user = f"Topic: {topic}\n\nReview this draft:\n---\n{draft}\n---\n"
raw, usage = self._backend.generate(EDITOR_SYSTEM, user, max_tokens=1024)
return Review(
approved=self._parse_verdict(raw),
feedback=self._parse_feedback(raw),
raw=raw,
usage=usage,
)
@staticmethod
def _parse_verdict(text: str) -> bool:
match = re.search(r"VERDICT:\s*(APPROVED|REVISE)", text, re.IGNORECASE)
# Default to REVISE if the model failed to state a clear verdict.
return bool(match) and match.group(1).upper() == "APPROVED"
@staticmethod
def _parse_feedback(text: str) -> str:
match = re.search(r"FEEDBACK:\s*(.*)", text, re.IGNORECASE | re.DOTALL)
return match.group(1).strip() if match else text.strip()
Human-in-the-Loop Control for the Writer-Editor Revision Loop
Here is the heart of it. The writer drafts once. Then we review, and while the editor is not satisfied, the writer revises and we review again. Three things can end the loop: the editor approves, the human says stop, or we run out of rounds.
Human-in-the-loop is a control pattern that keeps a person inside the decision loop of an otherwise automated process. The human check deserves a word: an automatic loop that only the editor can end is a loop that can burn tokens forever if the two models disagree politely. So each round, before we spend another revision, the human sees the verdict and decides whether to continue — even when the editor still wants changes. On a non-interactive run (no terminal attached), the check simply continues, which is what you want for scheduled or batch jobs.
import sys
def _ask_human(round_no: int, review: Review) -> bool:
"""Return True to keep iterating, False to stop."""
if not sys.stdin.isatty():
return True # non-interactive: don't block, just continue
verdict = "APPROVED" if review.approved else "REVISE"
print(f"\n[human-in-the-loop] Round {round_no} editor verdict: {verdict}")
answer = input("Continue iterating? [Y/n] ").strip().lower()
return answer in ("", "y", "yes")
def run_collaboration(backend, logger, config) -> str:
writer = WriterAgent(backend)
editor = EditorAgent(backend)
draft = writer.draft(config.topic)
logger.log(round_no=1, agent="writer", action="draft", content=draft)
for round_no in range(1, config.max_rounds + 1):
review = editor.review(config.topic, draft)
logger.log(
round_no=round_no, agent="editor", action="review",
content=review.raw,
verdict="APPROVED" if review.approved else "REVISE",
)
if review.approved:
break
if config.human_in_the_loop and not _ask_human(round_no, review):
logger.log(round_no=round_no, agent="human", action="stop",
content="Human halted the collaboration.")
break
if round_no == config.max_rounds:
break
draft = writer.revise(config.topic, draft, review.feedback)
logger.log(round_no=round_no + 1, agent="writer",
action="revise", content=draft)
config.output_path.write_text(draft, encoding="utf-8")
return draft
Notice that the writer and editor share one backend here. That is a convenience, not a rule. Hand EditorAgent a different backend from WriterAgent and you get a genuinely independent critic — a small change with a big effect on how honest the review feels.
Logging Agent Actions to CSV with Python’s csv Module
If two agents are going to argue about a draft, I want to read the argument afterwards. A CSVLogger gives me exactly that: one row per action, with its token usage and estimated cost, flushed to disk immediately so it survives a crash halfway through a run.
import csv
import datetime as dt
from pathlib import Path
class CSVLogger:
FIELDS = ["timestamp", "run_id", "round", "agent", "action", "verdict",
"model", "input_tokens", "output_tokens", "cost_usd",
"chars", "content"]
def __init__(self, path: Path, run_id: str):
self._path = Path(path)
self._run_id = run_id
write_header = not self._path.exists()
# newline="" is required by the csv module to avoid blank lines.
self._fh = self._path.open("a", newline="", encoding="utf-8")
self._writer = csv.DictWriter(self._fh, fieldnames=self.FIELDS)
if write_header:
self._writer.writeheader()
self._fh.flush()
def log(self, *, round_no, agent, action, content, verdict="",
model="", usage=None, cost_usd=0.0):
usage = usage or Usage()
self._writer.writerow({
"timestamp": dt.datetime.now().isoformat(timespec="seconds"),
"run_id": self._run_id, "round": round_no, "agent": agent,
"action": action, "verdict": verdict, "model": model,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cost_usd": f"{cost_usd:.6f}",
"chars": len(content), "content": content,
})
self._fh.flush() # so the log survives a crash mid-run
def close(self):
self._fh.close()
The two details that bite people: open the file with newline="" or the csv module sprinkles blank rows between every entry, and flush() after each write so a mid-run exception does not eat your history. A run_id per invocation lets you keep several runs in one file and still tell them apart later.
A short run produces something like this:
round,agent,action,verdict,model,input_tokens,output_tokens,cost_usd
1,writer,draft,,gpt-4o,500,800,0.009250
1,editor,review,REVISE,gpt-4o,1000,200,0.004500
2,writer,revise,,gpt-4o,500,800,0.009250
2,editor,review,APPROVED,gpt-4o,1000,200,0.004500
You can read the whole negotiation at a glance: draft, rejected, revised, approved — and exactly what each step cost. Load it into pandas and you can start asking duller but useful questions — how many rounds a topic typically needs, which agent burns the most tokens, whether the editor’s nitpicks are worth the revision spend.
Tracking Token Usage and Cost per LLM API Call
Watching tokens quietly add up is the fastest way to get a surprising bill. So each call is priced the moment it returns. Prices change constantly and differ per model, so I keep them in one editable table rather than scattered through the code — unknown models are simply treated as free and flagged in the summary.
# USD per 1,000,000 tokens as (input, output). Edit to match the current
# pricing page for your provider. Unknown models are treated as free.
PRICING = {
"claude-sonnet-5": (3.00, 15.00),
"gpt-4o": (2.50, 10.00),
"Qwen/Qwen2.5-7B-Instruct": (0.00, 0.00), # local model, no API charge
}
def estimate_cost(model: str, usage: Usage):
price_in, price_out = PRICING.get(model, (0.0, 0.0))
return (usage.input_tokens / 1_000_000 * price_in,
usage.output_tokens / 1_000_000 * price_out)
A small CostTracker accumulates the totals across the whole run and knows how to print itself. CostTracker keeps a per-agent breakdown too, so you can see whether the writer or the editor is the expensive one:
@dataclass
class AgentSpend:
usage: Usage = field(default_factory=Usage)
input_cost: float = 0.0
output_cost: float = 0.0
@property
def cost(self):
return self.input_cost + self.output_cost
class CostTracker:
def __init__(self):
self.total = Usage()
self.input_cost = 0.0
self.output_cost = 0.0
self.by_agent = {}
def add(self, model: str, usage: Usage, agent: str = "") -> float:
self.total += usage
in_cost, out_cost = estimate_cost(model, usage)
self.input_cost += in_cost
self.output_cost += out_cost
spend = self.by_agent.setdefault(agent or "unknown", AgentSpend())
spend.usage += usage
spend.input_cost += in_cost
spend.output_cost += out_cost
return in_cost + out_cost # this call's total cost, for the CSV row
@property
def total_cost(self):
return self.input_cost + self.output_cost
The orchestrator passes the agent name on each call — tracker.add(backend.model, usage, agent="writer") for a draft or revision, agent="editor" for a review — and the per-agent totals fall out for free.
The important part is when the summary prints. I want it whether the run finishes, the editor gives up, or I lose patience and hit Ctrl+C. That is a try/except KeyboardInterrupt with the summary in a finally, so the totals are the last thing you see no matter how the run ends:
def main(argv=None):
args = parse_args(argv)
run_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
backend = build_backend(args.provider, temperature=args.temperature)
logger = CSVLogger(Path(args.log), run_id=run_id)
tracker = CostTracker()
config = LoopConfig(topic=args.topic, max_rounds=args.max_rounds,
human_in_the_loop=not args.no_human,
output_path=Path(args.out))
try:
run_collaboration(backend, logger, config, tracker)
except KeyboardInterrupt:
print("\nInterrupted by user (Ctrl+C).")
finally:
logger.close()
print("\n" + tracker.summary(run_id))
return 0
Because the tracker lives in main and is updated as each call completes, an interrupt mid-run still reports everything spent up to that point. The output looks like this:
============================================================
Token usage summary (run 20260713-143012)
input tokens : 3,000 ($0.0075)
output tokens : 2,000 ($0.0200)
TOTAL cost : $0.0275
by agent:
editor : 2,000 in / 400 out ($0.0090)
writer : 1,000 in / 1,600 out ($0.0185)
============================================================
Input and output are reported separately on purpose. Output tokens usually cost several times more than input, and the per-agent split makes the pattern obvious: the writer’s long drafts dominate the output bill, while the editor spends most of its tokens reading the draft. If a run ever gets expensive, this line tells you which agent to rein in first.
Interchangeable LLM Backends: Anthropic, OpenAI, and Hugging Face
Now the fun part. The same two agents run on three very different engines. You pick one with --provider and nothing else changes.
Anthropic Backend: Claude Messages API Implementation
Claude is my default for this because it follows the structured-verdict instruction well and rarely wanders off format. The Anthropic Messages API returns content as a list of blocks, so we concatenate the text ones.
import os
class AnthropicBackend(LLMBackend):
name = "anthropic"
def __init__(self, model="claude-sonnet-5", temperature=0.7):
import anthropic # lazy import: only needed for this backend
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("Set ANTHROPIC_API_KEY to use this backend.")
self._anthropic = anthropic
self._client = anthropic.Anthropic(api_key=api_key)
self.model = model
self._temperature = temperature
self._send_temperature = True # newer models reject temperature
def generate(self, system, user, max_tokens=2048):
params = dict(model=self.model, max_tokens=max_tokens, system=system,
messages=[{"role": "user", "content": user}])
if self._send_temperature and self._temperature is not None:
params["temperature"] = self._temperature
try:
resp = self._client.messages.create(**params)
except self._anthropic.BadRequestError as exc:
# Some models deprecate `temperature`; drop it and retry once.
if self._send_temperature and "temperature" in str(exc).lower():
self._send_temperature = False
params.pop("temperature", None)
resp = self._client.messages.create(**params)
else:
raise
text = "".join(
b.text for b in resp.content if getattr(b, "type", "") == "text"
).strip()
if getattr(resp, "stop_reason", None) == "max_tokens":
_warn_truncated(params["max_tokens"]) # ran out of room mid-post
usage = Usage(resp.usage.input_tokens, resp.usage.output_tokens)
return text, usage
A wrinkle worth calling out: newer Claude models have deprecated the temperature parameter and will reject a request that includes it with a 400. Rather than hard-code which models allow it, the backend sends temperature, and if the API objects, it drops the parameter and retries once — then remembers the choice for the rest of the run. Older models keep their temperature control; newer ones just work.
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."
python agents.py --provider anthropic --topic "A practical guide to Python decorators"
OpenAI Backend: Chat Completions API Implementation
Almost identical, which is rather the point. The OpenAI Chat Completions API takes the system prompt as its own message, and the reply lives at choices[0].message.content.
class OpenAIBackend(LLMBackend):
name = "openai"
def __init__(self, model="gpt-4o", temperature=0.7):
from openai import OpenAI # lazy import
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("Set OPENAI_API_KEY to use this backend.")
self._client = OpenAI(api_key=api_key)
self.model = model
self._temperature = temperature
def generate(self, system, user, max_tokens=2048):
resp = self._client.chat.completions.create(
model=self.model, max_tokens=max_tokens,
temperature=self._temperature,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
choice = resp.choices[0]
text = (choice.message.content or "").strip()
if getattr(choice, "finish_reason", None) == "length":
_warn_truncated(max_tokens if max_tokens is not None else self.max_tokens)
usage = Usage(resp.usage.prompt_tokens, resp.usage.completion_tokens)
return text, usage
The two providers name their usage fields differently — input_tokens/output_tokens for Anthropic, prompt_tokens/completion_tokens for OpenAI — but both hand back an exact count, so we just read it off the response. Both also tell us why generation stopped: Anthropic’s stop_reason == "max_tokens" and OpenAI’s finish_reason == "length" mean the model ran out of room and the answer is truncated. That matters here — a writer that hits the cap gets cut off mid-code, and if we do not notice, the editor rejects it and the writer truncates again at the same spot, round after round. So each backend calls a small _warn_truncated() helper the moment it sees a cut-off, telling you to raise --max-new-tokens. The default writer cap is 4096, which is enough for a full post; the truncation warning is the safety net for when it is not.
pip install openai
export OPENAI_API_KEY="sk-..."
python agents.py --provider openai --topic "A practical guide to Python decorators"
Hugging Face Backend: Running Qwen2.5-7B-Instruct Locally on Apple M1
No API key, no per-token bill, no data leaving your laptop. I use Qwen2.5-7B-Instruct here because it writes clean, technically competent prose and, in bfloat16, fits comfortably on a 16 GB M1 through Apple’s Metal (MPS) backend. If memory is tight, a 4-bit GGUF via llama.cpp is lighter still.
The one detail that trips people up: instruct models expect a specific chat format, and the tokenizer’s apply_chat_template produces exactly the string the model was trained on. Do not hand-roll it.
class HuggingFaceBackend(LLMBackend):
name = "hf"
def __init__(self, model_id="Qwen/Qwen2.5-7B-Instruct",
temperature=0.7, max_tokens=2048):
import torch
import transformers
from transformers import AutoModelForCausalLM, AutoTokenizer
self._device = "mps" if torch.backends.mps.is_available() else "cpu"
self._torch = torch
self.model = model_id
self._temperature = temperature
self.max_tokens = max_tokens
print(f"[hf] loading {model_id} on device: {self._device}")
dtype = torch.bfloat16 if self._device == "mps" else torch.float32
# transformers 4.56 renamed `torch_dtype` to `dtype`. Pick the keyword the
# installed version expects, so we load in low precision without the warning.
dtype_kwarg = ("dtype"
if _version_at_least(transformers.__version__, (4, 56))
else "torch_dtype")
self._tokenizer = AutoTokenizer.from_pretrained(model_id)
self._model = AutoModelForCausalLM.from_pretrained(
model_id, **{dtype_kwarg: dtype}
).to(self._device)
def generate(self, system, user, max_tokens=None):
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
prompt = self._tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._device)
input_len = inputs["input_ids"].shape[-1]
cap = max_tokens if max_tokens is not None else self.max_tokens
with self._torch.no_grad():
generated = self._model.generate(
**inputs, max_new_tokens=cap, do_sample=True,
temperature=self._temperature, top_p=0.9,
pad_token_id=self._tokenizer.eos_token_id,
)
new_tokens = generated[0][input_len:]
# A local model has no stop_reason; producing exactly the cap almost
# always means it ran out of room rather than finishing naturally.
if len(new_tokens) >= cap:
_warn_truncated(cap)
text = self._tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
usage = Usage(int(input_len), int(len(new_tokens)))
return text, usage
There is no bill for a local model, but the tokenizer still gives us honest token counts — handy for comparing verbosity across providers even when the cost column reads zero.
Two small but real papercuts are handled here. First, transformers 4.56 renamed the torch_dtype load argument to dtype; passing the old name still works but nags with a deprecation warning, so a tiny version check picks the right keyword for whatever version you have installed. Second, the print of the resolved device is a cheap sanity check — if it says cpu instead of mps, that alone explains why generation is crawling.
pip install "transformers>=4.56" torch
python agents.py --provider hf --topic "A practical guide to Python decorators"
The first run downloads roughly 15 GB of weights, so be patient and be on decent Wi-Fi. After that it is all local. It is slower than the hosted APIs, and the heaviest call is the revision: it feeds the whole previous draft back in and generates a fresh one on top. If a run drags, the quickest relief is to cap the output length — generation time scales roughly with it:
# shorter answers per round -> noticeably faster local runs
python agents.py --provider hf --max-new-tokens 1200 \
--topic "A practical guide to Python decorators"
The --max-new-tokens flag sets the writer’s generation cap (the editor keeps its own short limit, since a review should be brief anyway). It costs nothing and it runs on a train with the Wi-Fi off, which counts for a lot.
Backend Comparison: Anthropic vs OpenAI vs Hugging Face
A quick, honest comparison rather than a sales pitch:
| Backend | Cost | Setup | Format discipline | Runs offline |
|---|---|---|---|---|
| Anthropic (Claude) | Per token | API key | Excellent | No |
| OpenAI (GPT) | Per token | API key | Excellent | No |
| Hugging Face (Qwen2.5-7B) | Free | ~15 GB download | Good, needs firmer prompting | Yes |
For a production pipeline where the verdict format must never slip, I would use a hosted API. For tinkering, for privacy, or simply for the pleasure of owning the whole stack, the local model is genuinely good enough — and there is something satisfying about two agents arguing about your prose entirely on your own machine.
Key Takeaways: Building a Writer-Editor Agent Loop in Python
Two small agents, one honest critic, and a loop with a human hand on the brake. The writer-editor agent loop is a two-agent critique-and-revise pattern that generalises well beyond blog posts — code review, test generation, documentation, anywhere a first draft benefits from a second opinion before it reaches you.
The parts worth keeping are the ones that took the most thought: default to REVISE when the verdict is unclear, flush the CSV log after every write, and always leave the human a way to stop. Swap the two agents’ backends and the reviewer stops agreeing with the writer out of family loyalty. Grab agents.py, point it at a topic, and watch the two of them work.
I am not claiming this loop replaces a human editor. It does not. But it catches the obvious things before I do, and it never gets tired of being asked to try again. Which, on a Friday afternoon, is about all I can ask.
Writer-Editor Agent Loop FAQ
Why does the editor default to REVISE instead of APPROVED when the verdict is unclear?
The EditorAgent._parse_verdict method only returns True (approved) when it finds an exact VERDICT: APPROVED match via regex. Any unparseable or ambiguous model output defaults to REVISE. This asymmetry is deliberate: an unnecessary revision round is cheaper than publishing a draft that a confused editor waved through.
How do I avoid blank rows when logging to CSV in Python?
Open the file with newline="" when creating the csv.DictWriter, for example path.open("a", newline="", encoding="utf-8"). Without it, Python’s csv module inserts a blank row between every entry on Windows and some other platforms.
Why does the Anthropic API reject the temperature parameter?
Newer Claude models have deprecated the temperature parameter and return a 400 BadRequestError if it is included. The fix is to catch the error, drop temperature from the request parameters, and retry once — then remember not to send it again for the rest of the run.
How do I run the writer-editor loop without an API key?
Use the Hugging Face backend with a local open-source model such as Qwen2.5-7B-Instruct, loaded via transformers.AutoModelForCausalLM on Apple’s Metal (MPS) backend or CPU. No ANTHROPIC_API_KEY or OPENAI_API_KEY is required, and no per-token cost is incurred.
How do I detect a truncated LLM response?
Check the provider’s stop signal: Anthropic sets stop_reason == "max_tokens", OpenAI sets finish_reason == "length", and a local Hugging Face model has generated exactly max_new_tokens with no natural stop. Any of these means the output was cut off mid-generation and max_tokens/--max-new-tokens should be raised.
References
- Anthropic Messages API — docs.anthropic.com
- OpenAI Chat Completions — platform.openai.com/docs
- Qwen2.5-7B-Instruct on Hugging Face — huggingface.co/Qwen/Qwen2.5-7B-Instruct
- Transformers chat templates — huggingface.co/docs/transformers/chat_templating
Enjoyed this? Get more like it.
Weekly notes on AI tools, Python, and what I'm actually building — plus a free copy of Fantastic AI: The 2026 Toolkit.