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. 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.