{"slug": "was-bash-the-wrong-language-for-my-agent", "title": "Was bash the wrong language for my agent?", "summary": "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.", "body_md": "I have a small agent that handles one piece of routine work at a time. It looks\n\nat what needs doing, picks the thing most worth doing, shows me the plan, and\n\ndoes it if I say yes. Underneath, it is glue: it drives a few command-line\n\ntools, calls a model, reshapes a lot of JSON, and prints a readable summary.\n\nIt was 2150 lines of bash across seven files. It is now Python.\n\nSo the answer looks like yes. I don't think it is, and why I don't is most of\n\nthe reason I'm writing this down.\n\nI had been through this question before and decided to stay — carefully enough\n\nthat 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\n\nI still think all of that is true.\n\nWhat moved was a requirement. I had been treating *runs with no build step* as\n\nhard, which made \"python3 is already installed\" the load-bearing argument. Then\n\nthe requirement got clarified: no build step isn't a rule, it just shouldn't be\n\ncomplicated to start.\n\nThat one sentence killed my best argument. So I measured what was actually at\n\nstake — 89ms for Python plus every standard library module it needs, against\n\n3ms for bash. Eighty-six milliseconds, in a program that waits thirty to a\n\nhundred seconds on a model.\n\n**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.\n\n`jq`\nEvery list length, every filter, forking a process to handle data that should\n\nhave been sitting in memory.\n\nThe cost was not performance. 162 forks are nothing next to a minute of model\n\nlatency. **The cost was expressiveness.** Every structure in the program either\n\nfit in a one-line `jq` expression or got split into three pieces. What I wrote\n\nwas never the structure I wanted; it was the structure `jq` could state on one\n\nline. That cost is invisible in any single line of code and shows up in the\n\ndesigns you never consider.\n\nI had three lists: the files a change actually touched, the files the agent\n\nitself had written, and the files I had approved in advance. I needed two\n\ndifferences between them.\n\n``` php\nlater=\"$(jq -nc --argjson a \"$actual\" --argjson w \"$written\" \\\n    'if $w == null then [] else ($a - $w) end')\"\nextra=\"$(jq -nr --argjson w \"$written\" --argjson p \"$planned\" \\\n    '($w - $p) | join(\", \")')\"\nlater = [] if written is None else [f for f in actual if f not in written]\nextra = [f for f in written if f not in planned]\n```\n\nThe second one is barely shorter. It is what I would have written on the first\n\nattempt; the first took me several tries to get right.\n\nThere was also a run that died on `line 567: 1: command not found`, which I\n\nnever located. It went away when that section was rewritten for unrelated\n\nreasons. **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.**\n\nThe port took a day.\n\n```\nbash      2150 lines\nPython    2258 lines   ← up 108\n\n            of which:\n  code      1278       ← down 40%\n  comments   980\n```\n\nMost people expect the opposite, so it's worth being blunt about. The code\n\nshrank by forty percent; the difference is comments and docstrings — the notes\n\nrecording which specific incident each safety check exists to prevent, which\n\nwere exactly what I'd been afraid of losing.\n\n**Evaluate this rewrite by total line count and it accomplished nothing.**\n\nThis is the part I actually wanted to write down.\n\n**The prompts are files, not strings.** Every prompt lives in its own Markdown\n\nfile and the code fills `{{placeholder}}` holes in it. I did that for unrelated\n\nreasons: prompts get edited constantly, they want to be read as prose, and a\n\nstray `$` or backtick has to stay inert instead of being eaten by the shell.\n\nThe result was that the migration **did not touch one word of any prompt**. I\n\nchecked modification times afterward to be sure. Everything that determines\n\nthis program's behavior — the criteria I've tuned over and over, the order\n\njudgments get made in — lives in those files. Changing languages only replaced\n\nthe glue that assembles them.\n\n**Each kind of task is a separate executable that speaks JSON.** Verb on argv,\n\nJSON on stdin, JSON on stdout. I built it that way because bash has no modules,\n\nand putting them behind a process boundary beat having them scribble on each\n\nother's variables. Pure coping.\n\nThe result: **there was no big-bang rewrite to choose.** The orchestrator could\n\nbe Python while the task types were still bash, or the reverse. I moved one\n\nfile at a time and ran each one on its own afterward. (That boundary is\n\nprobably also why this never hit the wall bash projects hit — the largest bash\n\nagent I know of reached 4700 lines as a single file assembled by `cat src/*.sh`,\n\nand what broke was module structure, not correctness.)\n\nNeither seam was built with portability in mind. Both were built to solve\n\nsomething annoying at the time.\n\n**Whether a rewrite will be cheap is decided before you start it.**\n\nI kept the process boundary afterward, by the way. Folding the task types into\n\nPython imports would save a JSON round trip, and it is the best structural\n\ndecision in the project.\n\nWith a shebang and standard library only, it is invoked exactly the way it was\n\nbefore. No virtualenv, no install.\n\nThe risk is that **Python invites dependencies**, and bash's poverty was itself\n\na form of protection. `requests` when `urllib` is right there; a schema\n\nvalidator when the model CLI already enforces the schema; an argument parser\n\nfor 25 lines of parsing; a formatting library for a display layer that exists.\n\nEach has a plausible case, and after all four \"quick to start\" is gone.\n\nSo 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\n\nI exercised every path after the port, including a full dry run of the\n\nexpensive one — isolated checkout, model writes the code, formatter, vet,\n\nbuild, tests, commit — stopping short of pushing. Fifteen tests on the pure\n\nfunctions pass.\n\n**The two lines that push a branch and open a change request never ran**,\n\nbecause running them means actually opening one. And the safety checks inside\n\nthe task types I translated by hand, one at a time. I believe I got them right,\n\nand this project still has no test that can prove it.\n\nI don't think bash was the wrong choice. It carried this to 2150 lines, and for\n\nall of that time I was changing judgment logic rather than fighting the\n\nlanguage. **Its problem was never that it couldn't do the job. Its problem was that its expressiveness had started deciding my designs.**", "url": "https://wpnews.pro/news/was-bash-the-wrong-language-for-my-agent", "canonical_source": "https://dev.to/pbxqdown/was-bash-the-wrong-language-for-my-agent-4i6l", "published_at": "2026-09-17 20:36:47+00:00", "updated_at": "2026-09-17 20:52:59.587759+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools"], "entities": ["Python", "bash", "jq"], "alternates": {"html": "https://wpnews.pro/news/was-bash-the-wrong-language-for-my-agent", "markdown": "https://wpnews.pro/news/was-bash-the-wrong-language-for-my-agent.md", "text": "https://wpnews.pro/news/was-bash-the-wrong-language-for-my-agent.txt", "jsonld": "https://wpnews.pro/news/was-bash-the-wrong-language-for-my-agent.jsonld"}}