DEV Community

Cover image for Agents Vs Scripts. Which Should You Choose?
Ifeanyi O. for AWS

Posted on • Originally published at builder.aws.com

Agents Vs Scripts. Which Should You Choose?

Intro

When someone wants to automate a boring and repetitive task, like triaging support emails, or renaming files the instinct now is "I'll build an agent for this."

What's crazy is, two years ago, you would have written a 30-line script and called it a day.

Today, agents are the new fun thing to build but people seem to have forgotten that a plain script can handle a lot of work many are building agents to do so knowing which one to chose to achieve a task will save you time, money and a lot of debugging. In this blog, I'll walk you through making an agent vs. script decision with an automated task.

We'll build on this task three ways to figure out which one you actually need. Let's review the task below:

You run a small SaaS company and the support inbox is currently piling up. You want incoming email sorted on its own so billing questions get routed to the billing team, bug reports route to the engineering team, and the "I want to cancel" ones get flagged before that person churns.

Definitions

People throw agents at anything that touches an LLM, so before we start comparisons, let's learn how a script and agent function based on who controls the flow.

In a script, you decide the control flow. In an agent, the model decides the control flow at runtime.

A script can call an LLM ten times and still be a script, because you wrote the order those calls happen in. However, with an agent, you hand the model a set of tools and a goal, and it decides what to call, in what order and when the job is done. There isn't a strict known path ahead of time.

The script

Let's start with the simplest thing that could work.

import re

RULES = [
    (re.compile(r"\b(refund|invoice|charge|billing|payment)\b", re.I), "billing"),
    (re.compile(r"\b(error|crash|broken|bug|500|not working)\b", re.I), "engineering"),
    (re.compile(r"\b(cancel|downgrade|unsubscribe)\b", re.I), "retention"),
]

def triage(email: str) -> str:
    for pattern, team in RULES:
        if pattern.search(email):
            return team
    return "unsorted"
Enter fullscreen mode Exit fullscreen mode

This is a list of keyword rules, where each rule holds a set of words and the team those words point to. Triage checks the email against each rule from top to bottom so the first one that matches returns its team, and anything that doesn't match anything falls through to unsorted.

This script runs in microseconds, virtually doesn't cost anything and routes the same email the same way every time, which means that if it sends an email to the wrong team we can see exactly which rule fired and fix that one line. On top of that, we can test it and read it in a single glance.

Now let's look at a more realistic scenario. One morning a customer sends in a message like this:

"Hey, every time I hit the pay button the whole thing just dies on me."

This is a real bug report email, but look at the words in it. The customer says "pay button" and "dies on me," but the billing rule only matches the full word "payment," never "pay," while the engineering rule is watching for "crash" or "broken" or "error," not "dies." None of these match the rules, as a results this crash report will drop into unsorted where no one on billing or engineering teams can review it.

We cannot classify this as bug in the code, since the code did exactly what we told it to. However, the issues is that the task overlooks steps we can't write down as rules like working out what the person actually meant by understanding the context.

Since there are endless ways to phrase the same request, a regex only knows the handful of patterns you fed it. We can keep bolting on new keywords, but that's just playing whack-a-mole against the whole English language, chasing one keyword at a time, which is a very tedious manual effort on a codebase that'll keep growing without real gain.

Keywords in code will never be context ware and understand meaning, however, that's actually the one job a model does well at. This is where we start thinking about maybe moving from a script to an agent.

The agent

For an agent, we hand the model a set of tools and let it manage the whole ticket process end to end using it's many tools. A tool is just a normal function you write (which can be arguable also just a script) like, look up a customer, open a bug ticket or send a reply, which you make available to the model so it can run that function itself when it decides it needs to. However, you're not calling these functions in a fixed order like you would in a script, instead you're handing them over and letting the model choose which ones to use.

tools = [
    look_up_customer,      # find the account from the sender's email
    get_recent_orders,     # pull their billing history
    issue_refund,          # actually move money
    create_bug_ticket,     # open a Jira/Linear issue
    reply_to_customer,     # send an email back
    escalate_to_human,     # punt to a person
]

agent = Agent(model="...", tools=tools, system=TRIAGE_POLICY)
agent.run(f"Handle this support email:\n\n{email}")
Enter fullscreen mode Exit fullscreen mode

From the list above, each line is one of those functions, and the comment beside it explains what the function does, so the model has six functions, from looking up who sent the email to issuing an actual refund.

Now, the model can run its own loop, read the email, decide to call look_up_customer, read the result, work out that the charge was a genuine double-bill, call issue_refund, then reply_to_customer, then stops.

It can also read the same email, decide it's above its pay grade and call escalate_to_human on the very first step. What's key here is you never needed to direct and hard code either of those paths, the model was able to autonomously make decisions to pick them at runtime.

The model handles the double-bill-crash email without breaking a sweat because it's reading the and understanding the context of the message instead of matching patterns. So for an open-ended task where you genuinely can't list all the branches up front, this is an approach more appropriate for an agent.

This approach is also not perfect, as we now handed a probabilistic system a tool called issue_refund that manipulated actual money. Since models are non-deterministic systems, some fraction of the time it's going to call that tool on a ticket where it shouldn't, because every capability you give an agent is another way for it to be confidently wrong at scale when making it's own decisions.

Pitfalls of using Agents

"Agents are expensive" gets repeated a lot, but it's too vague to make a real decision on, so let's break down what you're actually paying for.

The first is latency. The regex script executes in microseconds and a single model call runs somewhere between a few hundred milliseconds to a couple of seconds. However, an agent that takes 6 tool-calling round trips, for each step has to waits on the model, the tool, like a database query or an API, feeding the result back and waits on the model all over again.

Ten seconds to triage one email is not the end of the world especially if it's a background queue job, but for anything a person is sitting waiting on, this is not ideal.

Closely tied to latency is cost. An agent doesn't pay once per step, it pays for the entire conversation again on every step. On turn one the model reads the email, on turn two it re-reads the email plus its first decision and the tool result, then by turn six it's re-processing everything that came before on every single turn. Token usage grows roughly quadratically with the number of steps rather than linearly, so a task you pictured as "a few cents" can grow quickly once it's looping through tools on every ticket, multiplied by your daily volume.

Then there's determinism, or the lack of it. The same email can route two different ways on two different runs, and setting temperature to zero can help but it doesn't solve it, because tool results feed back into the context and shift the next decision, also a model version update can change the behavior overnight. For triaging, you can just shrug those off, but for anything consequential like money transactions or data deletion, non-determinism is a liability.

The last one is "debuggability". When the regex mis-routes, you get a stack trace pointing at a line, but when an agent mis-routes you have to sit and interpret a transcript of a conversation and it might not even be reproducible on the next run with the exact same input. You also don't debug an agent with a breakpoint, you debug it with tracing, eval sets and guardrails, which is a whole discipline that's you're responsibility.

This doesn't mean you should avoid agents, it just means you need to understand what you're taking on when an agent operates inside a system that runs critical operations.

The middle ground

Let's go back to why the script broke. It broke when trying to understand what the email meant, everything else, routing, priority logic, deciding who gets paged, was fine as plain code. So we don't actually have to replace the whole script for an ideal workflow, we just need to swap in a model on the one step that broke and leave the rest alone.

from pydantic import BaseModel
from typing import Literal
from openai import OpenAI

client = OpenAI()

class Triage(BaseModel):
    team: Literal["billing", "engineering", "retention", "other"]
    priority: Literal["low", "medium", "high"]
    reason: str

def triage(email: str) -> Triage:
    resp = client.responses.parse(
        model="gpt-5",
        input=[{"role": "user",
                "content": f"Triage this support email.\n\n{email}"}],
        text_format=Triage,
    )
    return resp.output_parsed
Enter fullscreen mode Exit fullscreen mode

Now, instead of letting the model reply with a paragraph you'd have to pick apart, you hand it a shape to fill in, a team, a priority and a short reason, and you get back a typed object with exactly those fields.

The model here only answers a question, what is this email about, and it doesn't touch a single tool. It never looks up the customer, opens a ticket, or issues a refund. It just reads the text and hands back three fields then your code takes over from there.

It reads the double-bill-crash email and returns engineering and high with a reason attached, because this time it understood the message rather than trying to "grep" it for keywords. As for the accounting request that used to vanish into unsorted, it now gets read and sorted like every other email.

We've made changes to our script an incorporated a model but what's most important here is that you still own the control flow.

result = triage(email)

route_to(result.team)
if result.priority == "high":
    page_on_call()
log_triage(email_id, result)
Enter fullscreen mode Exit fullscreen mode

Since there's no loop, there's no way for this to issue_refund, because you never gave it the ability to, it can only fill in the fields you defined. Cost is one bounded call per email, and latency is one round trip.

It's not fully deterministic, the model can still mis-categorize, but the blast radius of a wrong answer is "email went to the wrong queue," not "money left your account." When it's wrong, you log the input and the output, look at them side by side, and adjust the prompt or the schema. That's a debugging process you can actually live with.

This is the answer for the overwhelming majority of "should I use an agent" tasks. You don't always need a model to drive, but you can leverage to handle the steps your code can't, then hand control right back.

So, which one should you choose?

Here's the decision I actually run through, in order:

  1. Can you write down every step and every branch ahead of time? If yes, write a script, and don't add an agent just to feel modern.

  2. Is there exactly one step you can't express as code, usually "understand this messy input" or "generate this text?" Then it's a script with model calls inside it, structured output and a typed result with your code still deciding what happens next. This is the sweet spot and is somehow underused today.

  3. Does the task genuinely require deciding what to do and in what order based on information you won't have until runtime, across multiple tools, where you can't enumerate the paths? This is where you need to build an agent. Give it the narrowest set of tools that works, put a human in front of anything irreversible and set up tracing and evals from day one.

For our support inbox, the best answer is option two. A model reads the email and your code does the rest. The day you want the system to actually resolve tickets, look up the order, judge whether the refund is warranted, draft the reply and decide when a human needs to step in, then you'll need to use an agent and a very sophisticated one at that.

Top comments (0)