cd /news/ai-agents/was-bash-the-wrong-language-for-my-a… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-133028] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Was bash the wrong language for my agent?

A developer ported a 2,150-line bash agent that orchestrates command-line tools, calls a model, and reshapes JSON into 2,258 lines of Python, finding that the rewrite cut actual code by 40% while adding comments and docstrings. The engineer reports that keeping prompts in separate Markdown files with {{placeholder}} holes meant the migration touched no prompt text at all, and that the real cost of bash was not performance but expressiveness, since jq forced every data structure into one-line expressions.

by read6 min views3 publishedSep 17, 2026

I have a small agent that handles one piece of routine work at a time. It looks

at what needs doing, picks the thing most worth doing, shows me the plan, and

does it if I say yes. Underneath, it is glue: it drives a few command-line

tools, calls a model, reshapes a lot of JSON, and prints a readable summary.

It was 2150 lines of bash across seven files. It is now Python.

So the answer looks like yes. I don't think it is, and why I don't is most of

the reason I'm writing this down.

I had been through this question before and decided to stay β€” carefully enough

that the reasoning became a section of the project README titled Why this is still bash. Nine tenths of the program is subprocess orchestration, which is

I still think all of that is true.

What moved was a requirement. I had been treating runs with no build step as

hard, which made "python3 is already installed" the load-bearing argument. Then

the requirement got clarified: no build step isn't a rule, it just shouldn't be

complicated to start.

That one sentence killed my best argument. So I measured what was actually at

stake β€” 89ms for Python plus every standard library module it needs, against

3ms for bash. Eighty-six milliseconds, in a program that waits thirty to a

hundred seconds on a model.

My reasoning was valid; its inputs weren't, and I had spent almost no effort checking them. That ratio was backwards, and I don't think that's unusual.

jq Every list length, every filter, forking a process to handle data that should

have been sitting in memory.

The cost was not performance. 162 forks are nothing next to a minute of model

latency. The cost was expressiveness. Every structure in the program either

fit in a one-line jq expression or got split into three pieces. What I wrote

was never the structure I wanted; it was the structure jq could state on one

line. That cost is invisible in any single line of code and shows up in the

designs you never consider.

I had three lists: the files a change actually touched, the files the agent

itself had written, and the files I had approved in advance. I needed two

differences between them.

later="$(jq -nc --argjson a "$actual" --argjson w "$written" \
    'if $w == null then [] else ($a - $w) end')"
extra="$(jq -nr --argjson w "$written" --argjson p "$planned" \
    '($w - $p) | join(", ")')"
later = [] if written is None else [f for f in actual if f not in written]
extra = [f for f in written if f not in planned]

The second one is barely shorter. It is what I would have written on the first

attempt; the first took me several tries to get right.

There was also a run that died on line 567: 1: command not found, which I

never located. It went away when that section was rewritten for unrelated

reasons. A bug you can't find after the fact doesn't just go unfixed β€” it tells you the next one of its kind will too.

The port took a day.

bash      2150 lines
Python    2258 lines   ← up 108

            of which:
  code      1278       ← down 40%
  comments   980

Most people expect the opposite, so it's worth being blunt about. The code

shrank by forty percent; the difference is comments and docstrings β€” the notes

recording which specific incident each safety check exists to prevent, which

were exactly what I'd been afraid of losing.

Evaluate this rewrite by total line count and it accomplished nothing.

This is the part I actually wanted to write down.

The prompts are files, not strings. Every prompt lives in its own Markdown

file and the code fills {{placeholder}} holes in it. I did that for unrelated

reasons: prompts get edited constantly, they want to be read as prose, and a

stray $ or backtick has to stay inert instead of being eaten by the shell.

The result was that the migration did not touch one word of any prompt. I

checked modification times afterward to be sure. Everything that determines

this program's behavior β€” the criteria I've tuned over and over, the order

judgments get made in β€” lives in those files. Changing languages only replaced

the glue that assembles them.

Each kind of task is a separate executable that speaks JSON. Verb on argv,

JSON on stdin, JSON on stdout. I built it that way because bash has no modules,

and putting them behind a process boundary beat having them scribble on each

other's variables. Pure coping.

The result: there was no big-bang rewrite to choose. The orchestrator could

be Python while the task types were still bash, or the reverse. I moved one

file at a time and ran each one on its own afterward. (That boundary is

probably also why this never hit the wall bash projects hit β€” the largest bash

agent I know of reached 4700 lines as a single file assembled by cat src/*.sh,

and what broke was module structure, not correctness.)

Neither seam was built with portability in mind. Both were built to solve

something annoying at the time.

Whether a rewrite will be cheap is decided before you start it.

I kept the process boundary afterward, by the way. Folding the task types into

Python imports would save a JSON round trip, and it is the best structural

decision in the project.

With a shebang and standard library only, it is invoked exactly the way it was

before. No virtualenv, no install.

The risk is that Python invites dependencies, and bash's poverty was itself

a form of protection. requests when urllib is right there; a schema

validator when the model CLI already enforces the schema; an argument parser

for 25 lines of parsing; a formatting library for a display layer that exists.

Each has a plausible case, and after all four "quick to start" is gone.

So there is one rule in the README now: standard library only, and say why it can't be done with it before adding anything. The whole program needs six

I exercised every path after the port, including a full dry run of the

expensive one β€” isolated checkout, model writes the code, formatter, vet,

build, tests, commit β€” stopping short of pushing. Fifteen tests on the pure

functions pass.

The two lines that push a branch and open a change request never ran,

because running them means actually opening one. And the safety checks inside

the task types I translated by hand, one at a time. I believe I got them right,

and this project still has no test that can prove it.

I don't think bash was the wrong choice. It carried this to 2150 lines, and for

all of that time I was changing judgment logic rather than fighting the

language. Its problem was never that it couldn't do the job. Its problem was that its expressiveness had started deciding my designs.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @python 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/was-bash-the-wrong-l…] indexed:0 read:6min 2026-09-17 Β· β€”