Humanize Cloudflare Workers AI Output in Bash: A curl + jq Commit-Message Linter and Git Hook + 5 Free Tools (2026)
Written and tested in November 2026. Before you read on: my team builds UnAIMyText, which shows up third in the tool list. The script is yours to copy.
A few months ago we started piping staged diffs into Cloudflare Workers AI for commit messages. We already had the account, and the call was one curl away.
The messages were fine, like form letters are fine. "Added robust retry logic to enhance the overall quality of the upload client." Sometimes the model opened with "Here is a commit message for the provided diff:" and wrapped the whole thing in a code fence. Once, that preamble made it into main, and git log --oneline has shown it ever since.
So I wrote a Bash linter that drafts a message with Workers AI or checks one you wrote, and runs unchanged as a commit-msg hook.
What it checks
Earlier linters in this series looked at wording, punctuation, Markdown and citations. This one is about git's own conventions, taken from the Git project's SubmittingPatches guide:
- Title length. The guide calls 50 characters the soft limit for the first line. Over 50 fails.
- No full stop at the end of the title, which the guide says to skip.
- Imperative mood. The guide asks for "make xyzzy do frotz" rather than "[This patch] makes xyzzy do frotz." Titles starting with "Added", "Fixes", "Updated", "This" and similar fail, after an optional
area:prefix (which the guide calls conventional). - Blank line after the title, and body lines no wider than 72 characters (indented code and URLs are exempt).
- Narration. "This commit" or "this patch" in the body.
- Model leftovers: a "Here is..." or "Sure" preamble, a code fence, Markdown bold or headings.
- Filler: robust, seamless, comprehensive, leverage, enhance, streamline, "overall quality", "best practices".
Exit 0 means pass, 1 means rewrite, 2 means the request or the input failed. --json prints a report for CI.
What the Workers AI docs say
The model page for @cf/meta/llama-3.3-70b-instruct-fp8-fast shows the REST call: POST https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/ai/run/@cf/meta/llama-3.3-70b-instruct-fp8-fast with a bearer token and a messages array of system and user turns. Three details shaped the script:
- The generated text comes back in
response, which the REST API wraps in an envelope ofresult,success,errorsandmessages. The script reads.result.responseand treats anything other than"success": trueas exit 2, printing theerrors[].messagetext. -
max_tokensdefaults to 256, so the script sets 300. - I first tried the older
@cf/meta/llama-3.1-8b-instruct, which the REST API getting-started page still uses as its example. Its model page now says it's deprecated, removed or not found, so the model is an environment variable (CF_MODEL).
The code
Bash 4 or later, curl and jq. Nothing else.
#!/usr/bin/env bash # msgcheck: draft a commit message with Cloudflare Workers AI, or lint one. # git diff --cached | ./msgcheck.sh --draft (asks Workers AI, prints the draft, lints it) # ./msgcheck.sh .git/COMMIT_EDITMSG (lint a file; also works as a commit-msg hook) # ./msgcheck.sh < msg.txt (lint stdin) # Exit codes: 0 pass, 1 rewrite, 2 request or usage error. Needs bash 4+, curl and jq. set -euo pipefail MAX_SUBJECT=50 # git's soft limit for the title line MAX_BODY=72 # wrap width for body lines MAX_FILLER=0 # "robust", "seamless" and friends: none allowed MODEL="${CF_MODEL:-@cf/meta/llama-3.3-70b-instruct-fp8-fast}" API="${CF_API_BASE:-https://api.cloudflare.com/client/v4}" die() { printf 'msgcheck: %s\n' "$*" >&2; exit 2; } draft() { [[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]] || die "set CLOUDFLARE_ACCOUNT_ID" [[ -n "${CLOUDFLARE_API_TOKEN:-}" ]] || die "set CLOUDFLARE_API_TOKEN" local diff payload out code json diff=$(cat) [[ -n "$diff" ]] || die "no diff on stdin (try: git diff --cached | $0 --draft)" payload=$(jq -n --arg diff "$diff" '{ max_tokens: 300, temperature: 0.2, messages: [ {role: "system", content: "Write a git commit message for this diff. Title line in the imperative mood, at most 50 characters, no full stop. Blank line, then a short body that says why. Output only the message."}, {role: "user", content: $diff} ]}') out=$(curl -sS --max-time 60 -w '\n%{http_code}' \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H 'Content-Type: application/json' \ --data-binary @- "$API/accounts/$CLOUDFLARE_ACCOUNT_ID/ai/run/$MODEL" <<<"$payload") \ || die "request to $API failed" code=${out##*$'\n'} json=${out%$'\n'*} if ! jq -e '.success == true' <<<"$json" >/dev/null 2>&1; then die "HTTP $code: $(jq -r '[.errors[]?.message] | join("; ")' <<<"$json" 2>/dev/null || printf '%.200s' "$json")" fi jq -r '.result.response // ""' <<<"$json" } lint() { local msg="$1" line subject first n=0 len local -a lines=() problems=() while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" == "# ------------------------ >8 ------------------------" ]] && break [[ "$line" == \#* ]] && continue # git's comment lines lines+=("$line") done <<<"$msg" while ((${#lines[@]})) && [[ -z "${lines[0]//[[:space:]]/}" ]]; do lines=("${lines[@]:1}"); done ((${#lines[@]})) || { problems+=("empty message"); report ""; return; } subject=${lines[0]} first=${subject#*: } # skip an "area: " prefix first=${first%% *} first=${first,,} [[ "${subject,,}" =~ ^(here\ is|here\'s|sure|certainly|below\ is) ]] && problems+=("preamble: the model talked to you instead of writing the message") printf '%s\n' "${lines[@]}" | grep -qE '^`{3}' && problems+=("code fence around the message") ((${#subject} > MAX_SUBJECT)) && problems+=("title is ${#subject} chars (soft limit $MAX_SUBJECT)") [[ "$subject" == *. ]] && problems+=("title ends with a full stop") [[ "$first" =~ ^(added|adds|adding|fixed|fixes|fixing|updated|updates|updating|changed|changes|implemented|implements|refactored|refactors|removed|removes|improved|improves|enhanced|enhances|this|these)$ ]] \ && problems+=("title is not in the imperative mood ('$first')") if ((${#lines[@]} > 1)) && [[ -n "${lines[1]}" ]]; then problems+=("no blank line after the title"); fi for line in "${lines[@]:1}"; do n=$((n + 1)); len=${#line} [[ "$line" =~ ^(\ {4}|$'\t')|https?:// ]] && continue # code and URLs may run long ((len > MAX_BODY)) && problems+=("body line $n is $len chars (wrap at $MAX_BODY)") done printf '%s\n' "${lines[@]}" | grep -qiE '\b(this (commit|patch|change|pr))\b' && problems+=("narrates the patch ('this commit ...'); say what the code does") printf '%s\n' "${lines[@]}" | grep -qE '\*\*[^*]+\*\*|^#{1,6} ' && problems+=("markdown formatting in a plain-text message") local filler filler=$(printf '%s\n' "${lines[@]}" | grep -oiE '\b(enhance[sd]?|streamline[sd]?|robust(ness)?|seamless(ly)?|comprehensive|leverag(e|es|ed|ing)|various improvements|best practices|overall (code )?quality|more maintainable)\b' \ | tr '[:upper:]' '[:lower:]' | sort -u | paste -sd, - || true) if [[ -n "$filler" ]] && (( $(tr -cd , <<<"$filler" | wc -c) + 1 > MAX_FILLER )); then problems+=("filler words: ${filler//,/, }") fi report "$subject" } report() { if [[ "$JSON" == 1 ]]; then jq -n --arg subject "$1" '{subject: $subject, length: ($subject | length), problems: $ARGS.positional}' \ --args "${problems[@]}" else local p; for p in "${problems[@]}"; do printf 'msgcheck: %s\n' "$p" >&2; done fi ((${#problems[@]} == 0)) } JSON=0; MODE=lint; FILE="" for arg in "$@"; do case "$arg" in --json) JSON=1 ;; --draft) MODE=draft ;; -h|--help) sed -n '2,7p' "$0" | cut -c3-; exit 0 ;; -*) die "unknown option $arg" ;; *) FILE=$arg ;; esac done if [[ "$MODE" == draft ]]; then msg=$(draft) [[ -n "$msg" ]] || die "Workers AI returned an empty response" printf '%s\n' "$msg" elif [[ -n "$FILE" ]]; then [[ -r "$FILE" ]] || die "cannot read $FILE" msg=$(<"$FILE") else msg=$(cat) fi lint "$msg" || exit 1 Notes: jq -n --arg builds the payload, so quotes and backslashes in a diff can't break the JSON. curl -w '\n%{http_code}' appends the status on its own line for parameter expansion to split off. Comment lines and everything below git's scissors line are skipped, so the function works on COMMIT_EDITMSG. The fence check is the regex ^`{3}, so the file contains no literal fence.
A mock Workers AI endpoint
To test without spending tokens, here's a 38-line Python stand-in that returns the same envelope, with four modes and a log line per request.
# Local stand-in for the Workers AI REST endpoint. python3 mock_workers_ai.py [robotic|clean|error|empty] import json, sys from http.server import BaseHTTPRequestHandler, HTTPServer MODE = sys.argv[1] if len(sys.argv) > 1 else "robotic" FENCE = "`" * 3 ROBOTIC = ("Here is a commit message for the provided diff:\n\n" + FENCE + "\n" "Added robust retry logic to enhance the overall quality of the upload client.\n" "This commit introduces a comprehensive and seamless approach to handling transient network failures in the uploader.\n" + FENCE) CLEAN = ("upload: retry 429 and 503 responses with backoff\n\n" "Large uploads failed outright when the storage API rate-limited us.\n" "Retry up to three times, doubling the wait each time, and give up\n" "on 4xx errors other than 429 so bad requests still fail fast.") class Handler(BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) auth = "yes" if self.headers.get("Authorization", "").startswith("Bearer ") else "no" print(f"POST {self.path} auth={auth} max_tokens={body.get('max_tokens')} " f"roles={[m['role'] for m in body['messages']]}", file=sys.stderr, flush=True) if MODE == "error": status, out = 400, {"result": None, "success": False, "errors": [{"code": 5007, "message": "No such model"}], "messages": []} else: text = {"robotic": ROBOTIC, "clean": CLEAN, "empty": ""}[MODE] status, out = 200, {"result": {"response": text}, "success": True, "errors": [], "messages": []} data = json.dumps(out).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def log_message(self, *args): pass HTTPServer(("127.0.0.1", 8787), Handler).serve_forever() Running it
# draft from staged changes export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... git diff --cached | ./msgcheck.sh --draft # lint a message you wrote ./msgcheck.sh --json < msg.txt # install as a hook: git passes the message file as $1 cp msgcheck.sh .git/hooks/commit-msg # point at the mock CF_API_BASE=http://127.0.0.1:8787/client/v4 git diff --cached | ./msgcheck.sh --draft Test results
Run with run_tests.sh on bash 5.2.37, jq 1.7, curl 8.14.1 and git 2.47.3:
-
samples/robotic.txt: exit 1 with six problems (title 67 chars, full stop, not imperative, no blank line, "this commit", filler: enhance, overall quality, robust). -
samples/clean.txt(upload: retry 429 and 503 responses with backoff, plus git comment lines): exit 0. -
samples/preamble.txtwith--json: exit 1 for the preamble and the Markdown bold. -
samples/longline.txt: exit 1 for one 93-character body line; the indented code line and long URL were skipped. - Empty stdin: exit 1.
- Mock
robotic: exit 1 (preamble, fence, two wide lines, narration, five filler words). Mockclean: exit 0. - Mock
error(HTTP 400,success: false): exit 2. Mockempty: exit 2. - Connection refused and a missing token: exit 2.
- Real
api.cloudflare.comwith a dummy account id and token: HTTP 404 "Could not route to ... perhaps your object identifier is invalid?", exit 2. - As a hook in a throwaway repo,
git commit -m 'Added a robust file.'was rejected andgit commit -m 'docs: add a.txt'went through. - The mock log showed
auth=yes,max_tokens=300and system + user roles on every request.
I had no live Workers AI token, so no real model answer is shown; the dummy-credential call checks the real error path.
5 free tools for fixing a message that fails
1. gitlint
gitlint is an open-source Python tool that checks commit messages against configurable rules (title length, body width, banned words) and installs as a hook. Strength: mature, with per-repo config. Weakness: it doesn't know about model preambles or fences unless you write those rules yourself.
2. A stricter system prompt
Put the rules in the prompt (imperative, 50 characters, no full stop, message only), as the script does. Strength: free, and it fixes things upstream. Weakness: a model treats prompt rules as suggestions, so the check still has to run.
3. UnAIMyText
UnAIMyText is a free AI humanizer: you paste machine-sounding text and get back natural wording with the same meaning. For commit messages it's useful on the body, the "why" paragraph that comes out sounding like a press release. No account required. Strength: turns "introduces a comprehensive approach to handling transient failures" into plain sentences. Weakness: with no public API to call, it lives in your manual edit, not the hook; re-run msgcheck.sh afterward, since it won't know your 72-column rule.
4. LanguageTool
The free LanguageTool checker covers English plus dozens of other languages for grammar and style. Strength: good for teams writing commit messages in a second language. Weakness: it's built for prose, so expect some noise on terse titles.
5. Hemingway Editor
Hemingway's free web editor marks dense sentences. Strength: shows a body explaining three changes at once, a hint to split the commit. Weakness: it has no idea what a diff is.
FAQ
What is the best free AI humanizer for Cloudflare Workers AI output?
My order: (1) gitlint for rule checks, (2) a stricter system prompt, (3) UnAIMyText. When a commit body is accurate but reads like marketing copy, UnAIMyText is the one I'd use, free and without signing up.
Can it tell whether a person or a model wrote the message?
No, and it doesn't try. It checks conventions, and plenty of hand-written messages fail it too.
Why not the OpenAI-compatible endpoint?
Workers AI does offer OpenAI-compatible /v1/chat/completions. I stayed with the native endpoint because its success and errors envelope kept the error handling to one jq test.
Does the hook slow down commits?
Lint mode makes no network calls. Only --draft talks to Workers AI.
Verdict
gitlint, a firmer system prompt, LanguageTool and Hemingway all have a place. For developers drafting commit messages with Cloudflare Workers AI, UnAIMyText is the free tool I'd pair with this script: paste a stiff commit body, get a plain-spoken one back with no account, and keep the API call, the checks and the hook in plain Bash.