When not to build an agent made the case in the abstract: an agent is an LLM that controls its own control flow, and that control costs you quadratic tokens, serial latency, and a failure surface no unit test can cover, on every single run. What that post didn’t give you is the thing you reach for instead. This is that post — three pipeline shapes, each a fixed sequence of calls with no model-decided branching, that between them cover most of the tasks I’ve seen get a loop by default.
The shared property across all three: you can draw the flowchart of every possible run before you execute a single one. That’s the actual dividing line, not “does it call an LLM more than once” — all three shapes below call a model multiple times. What they don’t do is let the model decide, at runtime, what step comes next.
Shape 1: the linear chain
The simplest shape and the most commonly reached-for-a-loop task: a fixed sequence of steps, each feeding the next, where the order is known in advance even though the content isn’t. Extract, then validate, then format is the canonical example.
def process_ticket(raw_text: str) -> dict:
extracted = client.messages.create(
model="claude-sonnet-4-5", max_tokens=512,
messages=[{"role": "user", "content": f"Extract fields as JSON: {raw_text}"}],
)
fields = json.loads(extracted.content[0].text)
validated = client.messages.create(
model="claude-sonnet-4-5", max_tokens=256,
messages=[{"role": "user", "content": f"List any missing/invalid fields: {fields}"}],
)
issues = json.loads(validated.content[0].text)
if issues:
return {"status": "needs_review", "fields": fields, "issues": issues}
formatted = client.messages.create(
model="claude-sonnet-4-5", max_tokens=256,
messages=[{"role": "user", "content": f"Format for the ticketing API: {fields}"}],
)
return {"status": "ok", "payload": formatted.content[0].text}
Three model calls, zero loops. Nothing here decides “what to do next” at runtime beyond a single if issues branch, and that branch has exactly two known destinations. Compare this to an agentic version of the same task — a loop where the model decides after each step whether to extract again, validate again, or call a different tool — and you’re paying for a decision that, in the actual failure data, almost always resolves to “proceed to the next fixed step anyway.” You’re running an agent to reimplement a straight line.
The tell that you’ve over-built this into an agent: if you trace real runs and the “decide what’s next” step picks the same next step upward of, say, 95% of the time, you’ve built a loop around a straight line and paid the loop’s tax for the 5% case. Handle that 5% as an explicit branch (like issues above), not as license for the whole pipeline to become a loop.
Shape 2: router plus fixed handlers
The task genuinely needs a decision — but the decision is a single classification, not an open-ended sequence. A support ticket needs to go to one of five fixed playbooks; a document needs one of three fixed extraction templates. Bound the model’s discretion to exactly one classification call, then hand off to ordinary code:
HANDLERS = {
"billing": handle_billing_ticket,
"bug_report": handle_bug_ticket,
"access_request": handle_access_ticket,
"feature_request": handle_feature_ticket,
"other": handle_general_ticket,
}
def route_ticket(raw_text: str) -> dict:
classification = client.messages.create(
model="claude-haiku-4-5", max_tokens=32,
messages=[{"role": "user", "content":
f"Classify into exactly one of {list(HANDLERS)}: {raw_text}"}],
).content[0].text.strip()
handler = HANDLERS.get(classification, handle_general_ticket)
return handler(raw_text) # each handler is its own fixed chain (Shape 1)
This is the shape people mean when they say “the agent decides what to do” — and it’s true, narrowly. It decides once, among a known, enumerable set of outcomes, and every outcome routes to code you wrote and can test independently of the model. Compare that to a real agent loop, where the model can in principle keep re-deciding after every step, with a branching factor that compounds with every turn. A five-way classification with five fixed downstream chains is testable exhaustively — five cases, five expected handlers. A loop with five tools available at every one of ten steps has, in the worst case, five-to-the-tenth possible trajectories, and you will test approximately none of them.
Shape 3: fan-out / fan-in
Independent sub-tasks over a known list, with no dependency between them, then a fixed combine step. Summarizing forty documents and writing one digest is the standard example — each summary doesn’t need to know about the others, and the number of summaries is known before you start.
async def digest_documents(docs: list[str]) -> str:
summaries = await asyncio.gather(*[
summarize_one(doc) for doc in docs # N independent calls, fixed N, no loop
])
return client.messages.create(
model="claude-sonnet-4-5", max_tokens=1024,
messages=[{"role": "user", "content":
f"Combine these {len(summaries)} summaries into one digest:\n" + "\n---\n".join(summaries)}],
).content[0].text
This is the shape most likely to get mislabeled as needing an agent purely because it involves “a lot of LLM calls.” It doesn’t need control flow — it needs concurrency, which is a much cheaper problem. The fan-out calls parallelize (wall-clock cost is one call deep, not forty calls deep), each one fails and retries independently without touching the others, and the reduce step is a single deterministic hand-off. None of that requires anything to decide what happens next at runtime.
When you actually need the loop
All three shapes share the same limit: they work exactly as long as the sequence of steps is knowable ahead of time, even when the content of each step isn’t. The real test for whether a task needs a loop is whether the branching factor is knowable in advance — not whether the task is “complex,” not whether it calls a model more than once, but whether you can enumerate the graph of possible next-steps before you’ve seen this run’s intermediate results.
A debugging agent that has to decide, based on what a failing test actually says, whether to read a file, run a different test, or grep the codebase — and where that decision genuinely depends on content you can’t predict, and can recur an unknown number of times — is a real case for a loop. You can’t pre-draw that flowchart, because the number of nodes in it depends on what today’s failure looks like. That’s the difference between “the model picks one of five known destinations once” (Shape 2) and “the model picks the next of an unknown number of destinations, repeatedly, based on results it hasn’t seen yet” (an actual agent) — and it’s worth writing down the expected graph for your task before you build either one, because most tasks that people bring a loop to turn out, on inspection, to have already fully enumerable graphs.
What I’d actually do
- Draw the flowchart before you write the loop. If every path through it is nameable in advance, you have a pipeline in one of the three shapes above, not an agent problem.
- Bound “the model decides” to one classification, not an open sequence, wherever possible. A router is a single narrow decision with a fixed set of outcomes; a loop is an unbounded number of them.
- Reach for concurrency before you reach for control flow. A lot of “this needs an agent” tasks are actually “this needs N independent calls run in parallel,” which Shape 3 solves without any decision-making at all.
- Save the loop for unknown branching factor, not for “many steps.” Ten known steps in a row is still a pipeline. Three steps where the third one’s existence depends on what the second one returned is where an agent starts paying for itself.