cd /news/large-language-models/building-agentic-workflows-in-python · home topics large-language-models article
[ARTICLE · art-48047] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=· neutral

Building Agentic Workflows in Python

A developer outlines best practices for building agentic workflows in Python, defining an agent as a loop where the model decides which tool to call next until completion. The post provides a manual loop implementation with safety controls like iteration caps and validation, and advises using agents only for genuinely multi-step, open-ended tasks.

read5 min views1 publishedJul 4, 2026

"Agent" has become the word for any program that calls an LLM more than once, which makes it a word worth being precise about. An agent, in the sense this post uses, is a loop: the model decides which tool to call next, your code executes it, and the result feeds back in — repeating until the model decides it's done. That's a genuinely different (and riskier) shape than a single request/response call.

This post builds on Building Reliable LLM Applications in Python: everything said there about retries, structured output, and evaluation still applies once you add a loop — it just applies to every iteration, and now the model is also choosing which side effects to trigger. We'll cover when an agent is actually warranted, the loop itself (manual and SDK-assisted), and the safety controls that make handing a model the wheel defensible.

Reach for an agent only when the task is genuinely multi-step and open-ended: the number and order of actions can't be known ahead of time, so a fixed pipeline can't express it. Most tasks that feel agentic are actually better served by something simpler and more debuggable. There's a ladder, and you should stop climbing it the moment the task is satisfied:

Before building step 3, run the task past four checks. If any answer is "no," stay at step 1 or 2:

An agent is a deliberate escalation, not a default. Most production LLM features never need one.

Once an agent is warranted, the shape is the same regardless of the tools involved: call the model with a list of available tools; if it responds asking to use one (stop_reason == "tool_use"

), execute that tool in your own code and send the result back as a tool_result

; repeat until the model responds with end_turn

. Two ways to run that loop in Python — write it by hand for full control, or let the SDK's tool runner drive it for you.

Writing the loop yourself means every tool call passes through your code before it executes, which is where you validate arguments, log the decision, and gate anything irreversible:

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from env — never hardcode

MAX_ITERATIONS = 10

messages = [{"role": "user", "content": user_input}]
iterations = 0

while True:
    iterations += 1
    if iterations > MAX_ITERATIONS:
        raise RuntimeError("Agent exceeded iteration cap — stopping")

    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=16000,
        thinking={"type": "adaptive"},
        tools=tools,
        messages=messages,
    )

    if response.stop_reason == "end_turn":
        break

    tool_use_blocks = [b for b in response.content if b.type == "tool_use"]

    messages.append({"role": "assistant", "content": response.content})

    tool_results = []
    for tool in tool_use_blocks:
        result = execute_validated_tool(tool.name, tool.input)
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": tool.id,
            "content": result,
        })
    messages.append({"role": "user", "content": tool_results})

final_text = next(b.text for b in response.content if b.type == "text")

Two things earn their keep here that a convenience runner would hide: the MAX_ITERATIONS

cap, and the log point right before the tool result round-trip. Both are cheap to add and expensive to retrofit after an agent has looped in production for an hour.

When you don't need to intercept every call — a low-stakes, read-only agent, or a prototype — the beta tool runner drives the same loop for you. Decorate a plain function with @beta_tool

; its docstring becomes the tool description the model sees:

from anthropic import beta_tool

@beta_tool
def get_weather(location: str) -> str:
    """Get current weather for a location.

    Args:
        location: City and state, e.g. San Francisco, CA.
    """
    return f"Sunny, 72°F in {location}"

runner = client.beta.messages.tool_runner(
    model="claude-opus-4-8",
    max_tokens=16000,
    tools=[get_weather],
    messages=[{"role": "user", "content": "Weather in Paris?"}],
)
for message in runner:
    ...  # each iteration is a BetaMessage; loop ends when Claude is done

The trade-off is explicit: the runner is fewer lines, but your validation and approval logic has to live inside the tool function rather than at a single choke point between the model and execution. For anything past a read-only demo, the manual loop's explicit checkpoint is worth the extra code.

The loop's shape — how many iterations are allowed, what counts as done, how a failed tool call is retried — belongs in Python, not in a system prompt asking the model to "keep trying until it works." As covered in Building Reliable LLM Applications in Python, use the model for judgment (which tool, with what arguments, when to stop) and code for bookkeeping (the loop, the retry policy, the cap, the audit log). An agent that reasons its own way through retry logic in natural language is slower, more expensive, and less predictable than an except

block that already knows what to do with a transient failure.

Free-text hand-offs between agent steps are where errors compound silently — a slightly malformed field from step two becomes a wrong argument in step three's tool call. Where a step's output needs to be used by the next step (not just displayed to a person), get it back as a validated, typed object instead of prose to re-parse:

from pydantic import BaseModel

class PlanStep(BaseModel):
    action: str
    done: bool

response = client.messages.parse(
    model="claude-opus-4-8",
    max_tokens=16000,
    messages=[{"role": "user", "content": "What is the next step, and are we done?"}],
    output_format=PlanStep,
)

step = response.parsed_output   # a validated PlanStep, not a string to parse
if step.done:
    ...  # stop the loop deterministically — no guessing from prose

A validated PlanStep

either parses or raises; there's no regex trying to guess whether the model meant "done" or "we're basically done."

An agent is a program that decides, at runtime, which of your functions to call and with what arguments — based on text it read. Treat every tool as an attack surface accordingly:

tool.input

(or a tool function's arguments) is model-provided data and must be treated as untrusted, exactly like a request body from the network. Whitelist allowed values, bound numeric ranges, and reject anything that doesn't fit the tool's contract MAX_ITERATIONS

(or a wall-clock timeout). Without one, a confused model can loop indefinitely, burning tokens and possibly retrying a failing tool call forever.response.usage

per turn and alert on runaway loops the same way you'd alert on a runaway retry storm.ANTHROPIC_API_KEY

via anthropic.Anthropic()

— no key ever appears in source, config committed to version control, or logs.

── more in #large-language-models 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-agentic-wor…] indexed:0 read:5min 2026-07-04 ·