# Was bash the wrong language for my agent?

> Source: <https://dev.to/pbxqdown/was-bash-the-wrong-language-for-my-agent-4i6l>
> Published: 2026-09-17 20:36:47+00:00

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.

``` php
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.**
