Humanize Together AI Output in Elixir: A Pull Request Description Linter + 5 Free Tools (2026)
Humanize Together AI Output in Elixir: A Pull Request Description Linter + 5 Free Tools (2026) Built and tested on 2026-11-07. Before the tool list: I work on UnAIMyText, so it sits third on purpose. The Elixir file is
Humanize Together AI Output in Elixir: A Pull Request Description Linter + 5 Free Tools (2026)
Built and tested on 2026-11-07. Before the tool list: I work on UnAIMyText, so it sits third on purpose. The Elixir file is free to copy.
Our Phoenix repo started getting pull requests whose descriptions opened with "This PR aims to seamlessly enhance the overall reliability of our robust webhook pipeline." A teammate had wired an open-weight model on Together AI into a little "describe my diff" helper. The diffs were fine. The descriptions made reviewers scroll past the one sentence they needed: what changed, and how it was tested.
So I wrote a one-file Elixir script. Give it change notes and it asks Together for a description, then grades the answer. Give it a Markdown file and it just grades.
The rules
Each check targets something a reviewer actually trips over:
- First line β€ 72 characters. It becomes the PR title in a lot of workflows and should read like one.
- No assistant warm-up. "Sure!", "Here is", "Certainly" at the top is a fail.
- No fence wrapper. Models love returning the whole answer inside a code block.
- No "This PR aims toβ¦" opener. Say what changed instead of announcing that something will be said.
- Filler lexicon. seamless, leverage, robust, comprehensive, streamline, delve, elevate, cutting-edge, as an AI, and friends.
-
A Testing line must exist.
Testing:,Tested:,Test planorHow to test, as a line or heading. - Bullet walls. More than eight bullets means nobody will read them.
- Emoji. One is a choice; two is a rocket-ship habit.
Exit codes: 0 when the description is ready to open, 1 if a human should take another swing at it, 2 if the HTTP call or the input file broke. --json prints a machine-readable verdict for CI.
What Together's docs say
I followed Together AI's chat completions reference. The parts this script depends on:
-
POST https://api.together.ai/v1/chat/completionswithAuthorization: Bearer $TOGETHER_API_KEYand a JSON body. - A request carries
modelplusmessages(each entry has aroleand itscontent); I addmax_tokens: 400so a chatty model can't write an essay. - The reply text sits at
choices[0].message.content. - Together's OpenAI-compatibility page says existing OpenAI clients work by swapping the key and the base URL to
https://api.together.ai/v1, which is why theTOGETHER_BASE_URLoverride is all the mock needs.
The default model is the one used in the reference examples (Qwen/Qwen3.5-9B); set TOGETHER_MODEL to use another.
The script
Erlang/OTP and Elixir only. :httpc handles the request, :ssl verifies the certificate against the OS trust store via :public_key.cacerts_get/0, and Elixir's built-in JSON module encodes and decodes. No Mix, no Hex, no Req.
# pr_lint.exs: draft a pull request description with Together AI chat completions, or lint one.
# elixir pr_lint.exs --draft "retry webhook sends, add jitter, cap at 5 tries"
# elixir pr_lint.exs samples/robotic.md
# elixir pr_lint.exs --json samples/robotic.md
# Exit 0 = ready to open, 1 = rewrite it, 2 = the HTTP call or the input failed.
# Elixir + Erlang/OTP stdlib only (:httpc, :ssl, JSON). No Hex packages.
defmodule PrLint do
@max_title 72
@max_bullets 8
@preamble ~r/\A\s*(sure|certainly|absolutely|of course|here(?:'s| is| are))\b/i
@boilerplate ~r/\A\s*(#+\s*)?(summary\s*:?\s*)?(this (pr|pull request) (aims|seeks|is designed) to|in this (pr|pull request),? (we|i))/i
@filler ~r/\b(seamless(ly)?|leverag(e|es|ing)|robust|comprehensive|streamlin(e|es|ed)|enhanc(e|es|ed) the overall|delve|elevate|game[- ]changer|cutting[- ]edge|as an ai|ensure a smooth|holistic|paradigm)\b/i
@testing ~r/^\s*(#+\s*)?(\*\*)?(testing|tested|test plan|how to test|verification)\b/im
@bullet ~r/^\s*[-*+]\s+/m
def fence, do: String.duplicate("`", 3)
def lint(text) do
t = String.trim(text)
if t == "" do
["empty description"]
else
title = t |> String.split("\n", parts: 2) |> hd() |> String.trim_leading("#") |> String.trim()
bullets = length(Regex.scan(@bullet, t))
emoji = t |> String.to_charlist() |> Enum.count(&emoji?/1)
[
{String.length(title) > @max_title, "first line is #{String.length(title)} chars (keep it to #{@max_title})"},
{Regex.match?(@preamble, t), "model preamble (Here is / Sure / Certainly)"},
{String.starts_with?(t, fence()), "whole description wrapped in a code fence"},
{Regex.match?(@boilerplate, t), "boilerplate opener (This PR aims to ...)"},
{Regex.match?(@filler, t), "robotic filler phrase"},
{not Regex.match?(@testing, t), "no Testing / Test plan line"},
{bullets > @max_bullets, "#{bullets} bullets (reviewers skim; keep it to #{@max_bullets})"},
{emoji > 1, "more than one emoji (#{emoji})"}
]
|> Enum.filter(&elem(&1, 0))
|> Enum.map(&elem(&1, 1))
end
end
defp emoji?(c), do: c in 0x1F300..0x1FAFF or c in 0x2600..0x27BF
def draft(notes) do
key = System.get_env("TOGETHER_API_KEY") || die("TOGETHER_API_KEY is not set")
base = System.get_env("TOGETHER_BASE_URL", "https://api.together.ai") |> String.trim_trailing("/")
model = System.get_env("TOGETHER_MODEL", "Qwen/Qwen3.5-9B")
body =
JSON.encode!(%{
"model" => model,
"max_tokens" => 400,
"messages" => [
%{"role" => "system",
"content" => "You write GitHub pull request descriptions. Plain text or light markdown. " <>
"First line: what changed, under 72 characters. Then why, then a Testing line. " <>
"No preamble, no code fence around the answer, no marketing words."},
%{"role" => "user", "content" => "Change notes: " <> notes}
]
})
{:ok, _} = Application.ensure_all_started(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
url = String.to_charlist(base <> "/v1/chat/completions")
headers = [{~c"authorization", String.to_charlist("Bearer " <> key)}]
http_opts = [
timeout: 30_000,
connect_timeout: 10_000,
ssl: [
verify: :verify_peer,
cacerts: :public_key.cacerts_get(),
customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
]
]
case :httpc.request(:post, {url, headers, ~c"application/json", body}, http_opts, body_format: :binary) do
{:ok, {{_, status, _}, _h, resp}} when status in 200..299 ->
case JSON.decode(resp) do
{:ok, %{"choices" => [%{"message" => %{"content" => c}} | _]}} when is_binary(c) and c != "" -> c
_ -> die("no choices[0].message.content in the response")
end
{:ok, {{_, status, _}, _h, resp}} ->
die("HTTP #{status}: #{String.slice(resp, 0, 200)}")
{:error, reason} ->
die("request failed: #{inspect(reason)}")
end
end
def die(msg) do
IO.puts(:stderr, "pr_lint: " <> msg)
System.halt(2)
end
def main(args) do
{json?, rest} = {"--json" in args, Enum.reject(args, &(&1 == "--json"))}
{source, text} =
case rest do
["--draft", notes | _] -> {"together", draft(notes)}
[path | _] ->
case File.read(path) do
{:ok, t} -> {path, t}
{:error, why} -> die("cannot read #{path}: #{why}")
end
[] -> die("usage: elixir pr_lint.exs [--json] (--draft \"notes\" | file.md)")
end
problems = lint(text)
if json? do
IO.puts(JSON.encode!(%{"source" => source, "ok" => problems == [], "problems" => problems}))
else
if source == "together", do: IO.puts(text <> "\n---")
if problems == [], do: IO.puts("ok: #{source}"), else: Enum.each(problems, &IO.puts("#{source}: #{&1}"))
end
System.halt(if problems == [], do: 0, else: 1)
end
end
unless System.get_env("PR_LINT_NO_MAIN"), do: PrLint.main(System.argv())
The fence check builds its marker with String.duplicate/2; with no three-backtick run anywhere in the file, it pastes into a Markdown post without breaking the code block.
The test suite
ExUnit runs straight from a script; the env var stops pr_lint.exs from calling main/1 when it is required.
# PR_LINT_NO_MAIN=1 elixir pr_lint_test.exs
ExUnit.start()
Code.require_file("pr_lint.exs", __DIR__)
defmodule PrLintTest do
use ExUnit.Case, async: true
test "clean sample passes" do
assert PrLint.lint(File.read!("samples/clean.md")) == []
end
test "robotic sample collects several problems" do
p = PrLint.lint(File.read!("samples/robotic.md"))
assert "model preamble (Here is / Sure / Certainly)" in p
assert "robotic filler phrase" in p
assert "no Testing / Test plan line" in p
assert Enum.any?(p, &String.starts_with?(&1, "more than one emoji"))
end
test "bullet wall is flagged" do
assert Enum.any?(PrLint.lint(File.read!("samples/long.md")), &String.contains?(&1, "11 bullets"))
end
test "boilerplate opener and long first line" do
p = PrLint.lint("This PR aims to fix the thing that has been bothering everyone on the team since last March\n\nTesting: ran it")
assert "boilerplate opener (This PR aims to ...)" in p
assert Enum.any?(p, &String.starts_with?(&1, "first line is"))
end
test "fence wrapper and empty input" do
f = PrLint.fence()
assert "whole description wrapped in a code fence" in PrLint.lint(f <> "\nFix\n\nTesting: yes\n" <> f)
assert PrLint.lint(" ") == ["empty description"]
end
end
The mock server
A stdlib Python stand-in for the endpoint, so CI never needs a real key. It logs what the client sent.
#!/usr/bin/env python3
# Local fake of Together's /v1/chat/completions endpoint. Standard library only.
# usage: python3 mock_together.py MODE PORT (MODE: robotic | clean | untested | error | empty)
import json
import sys
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
args = sys.argv[1:] + ["robotic", "8799"][len(sys.argv[1:]):]
MODE, PORT = args[0], int(args[1])
TICKS = "`" * 3
CANNED = {
"robotic": "Certainly! Here is your PR description:\n" + TICKS + "\n"
"This pull request aims to leverage a robust, comprehensive retry layer.\n" + TICKS,
"clean": "Retry webhook sends with jitter, capped at 5 tries\n\n"
"A single 502 used to drop the event. Sends now back off and stop after 5 attempts.\n\n"
"Testing: backoff unit tests and a stub endpoint that fails twice.",
"untested": "Retry webhook sends with jitter\n\nA single 502 used to drop the event.",
"empty": "",
}
class FakeTogether(BaseHTTPRequestHandler):
def reply(self, code, obj):
raw = json.dumps(obj).encode()
self.send_response(code)
for k, v in (("Content-Type", "application/json"), ("Content-Length", str(len(raw)))):
self.send_header(k, v)
self.end_headers()
self.wfile.write(raw)
def do_POST(self):
sent = self.rfile.read(int(self.headers.get("Content-Length") or 0))
try:
req = json.loads(sent)
except ValueError:
req = {}
bearer = self.headers.get("Authorization", "").startswith("Bearer ")
roles = [m.get("role") for m in req.get("messages", [])]
print(f"path={self.path} auth={'yes' if bearer else 'no'} ctype={self.headers.get('Content-Type')} "
f"model={req.get('model')} max_tokens={req.get('max_tokens')} roles={roles}", flush=True)
if MODE == "error":
return self.reply(401, {"error": {"message": "Invalid API key", "type": "invalid_request_error"}})
choice = {"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": CANNED[MODE]}}
self.reply(200, {"id": "mock-1", "object": "chat.completion", "model": req.get("model"), "choices": [choice]})
def log_message(self, *_):
return
ThreadingHTTPServer(("127.0.0.1", PORT), FakeTogether).serve_forever()
Running it
# needs Elixir 1.18+ (built-in JSON) and OTP 25+ (:public_key.cacerts_get)
export PATH="$HOME/tools/elixir/bin:$HOME/tools/otp/bin:$PATH"
# lint a saved description
elixir pr_lint.exs samples/clean.md
# ask Together for a draft, then lint it
export TOGETHER_API_KEY=...
elixir pr_lint.exs --draft "retry webhook sends, add jitter, cap at 5 tries"
# point it at the mock
python3 mock_together.py robotic 8799 &
TOGETHER_API_KEY=test-key TOGETHER_BASE_URL=http://127.0.0.1:8799 \
elixir pr_lint.exs --draft "retry webhook sends"
# unit tests
PR_LINT_NO_MAIN=1 elixir pr_lint_test.exs
Test results (2026-11-07, Elixir 1.20.5 on Erlang/OTP 27, Linux x86_64)
I installed the official OTP 27.3.4.9 build from builds.hex.pm and the elixir-otp-27 release zip from GitHub (checksums matched), both under /workspace/tools. No Mix project and no Hex packages: :httpc, :ssl and Elixir's built-in JSON module do the work. The stand-in API is mock_together.py (Python http.server, stdlib only).
What run_tests.sh printed:
- ExUnit suite (
PR_LINT_NO_MAIN=1 elixir pr_lint_test.exs) β 5 tests, 0 failures -
samples/robotic.mdβ 1 (preamble, filler, no Testing line, two emoji) -
samples/clean.mdβ 0 -
--json samples/long.mdβ 1 (11 bullets against a cap of 8) - missing path β 2
- no arguments β 2
- mock robotic β 1
- mock clean β 0
- mock untested (no Testing line) β 1
- mock error (HTTP 401) β 2
- mock empty content β 2
- connection refused β 2
-
TOGETHER_API_KEYunset β 2 - real
api.together.aiwith a fake key β 2 (HTTP 401 "Invalid API key provided"), which also proved the TLS path verifies the certificate
The mock logged auth=yes ctype=application/json model=Qwen/Qwen3.5-9B max_tokens=400 roles=['system', 'user'] for all five draft calls. Nothing was billed: no funded Together key exists on the box. Triple-backtick literals in the .exs files: 0.
5 free tools for the wording the linter rejects
1. A GitHub pull request template
A .github/pull_request_template.md with "What changed", "Why" and "Testing" headings pre-fills every new PR. Where it shines: the Testing line exists before anyone, human or model, starts typing. Where it falls short: a template can't stop "robust" and "seamless" from filling the blanks.
2. Hemingway Editor
The free web editor highlights long, dense sentences. Where it shines: the "why" paragraph that grew three subordinate clauses. Where it falls short: it has no idea what a reviewer needs to know about your diff.
3. UnAIMyText
UnAIMyText rewrites generated-sounding prose so it reads like a person wrote it, free and without an account. I paste only the "why" sentences; function names, ticket IDs and numbers stay out. Where it shines: "This PR aims to seamlessly enhance the overall reliability of our webhook pipeline" became "Webhook sends now retry with jitter instead of giving up after one 502." Where it falls short: there's no public API, so it lives outside the script; I re-run pr_lint.exs on the result so the title length and Testing line still pass.
4. LanguageTool
The free extension works inside GitHub's comment box. Where it shines: teams writing in a second language, where word order drifts. Where it falls short: correct grammar can still describe nothing.
5. Grammarly
The free tier catches typos in the title. Where it shines: a misspelled module name in the first line is embarrassing in the merge log. Where it falls short: its "engaging" suggestions push toward the exact tone this linter fails.
What never goes through a humanizer
Module names, migration names, issue numbers, benchmark figures and the test commands. Those are facts reviewers copy and paste. Only the explanatory sentences get rewritten, then the linter runs again.
FAQ
What is the best free AI humanizer for Together AI output?
For PR descriptions: (1) a PR template so structure exists, (2) Hemingway for the long sentence, (3) UnAIMyText for the "why" paragraph once the facts are right and only the tone is off. For that final pass UnAIMyText is the free, no-signup choice.
Why Elixir and not a Mix task?
A single .exs file is easier to read in a post and runs anywhere Elixir does. Wrap it in a Mix task or a GitHub Action once it earns its place.
Verdict
A PR template, Hemingway, LanguageTool and Grammarly each catch part of the problem. For teams generating pull request descriptions with Together AI, UnAIMyText is my free companion to this linter: keep the facts in your hands, paste the inflated "why" sentence, and let a short Elixir script enforce the title length and the Testing line.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.