DEV Community

UnAIMyText
UnAIMyText

Posted on

Humanize Cohere API Output in TypeScript: A Tone Linter for Filler, Lists of Three and Same-Size Paragraphs + Top 5 Free Tools (2026)

Updated October 2026. Disclosure: UnAIMyText is my team's product and it's third in the list below. The TypeScript below is free to reuse.

Cohere's Command models are a popular pick for generating product copy in bulk: onboarding emails, help-center stubs, store listings, in-app tips. Generation is the easy part. The hard part is noticing, before the copy ships, that every paragraph says "Additionally," lists exactly three benefits and runs about forty words.

None of those habits is wrong on its own. Stacked together on a real page, they make readers feel they're reading a template. So instead of eyeballing every response, I wrote a tiny TypeScript linter that flags the three patterns I see most in generated product copy and exits non-zero so CI can stop the build. Then I'll rank five free tools for rewriting whatever it catches.

What tonelint checks

Earlier posts in this series looked at stock words, sentence rhythm and repeated openers. This one looks at three different habits:

  1. Filler density. Transition words and padding phrases ("additionally," "it's worth noting," "plays a crucial role") counted per 100 words. Two or more hits above 1.5 per 100 words fails.
  2. Lists of three. The "fast, simple, and reliable" pattern. Three or more of them, at a rate above one per 120 words, fails. One triplet is rhetoric; five in a short blurb is a tic.
  3. Same-size paragraphs. It measures the spread of paragraph word counts (standard deviation over mean), ignoring headings and lines under eight words. With three or more paragraphs and a spread below 0.2, every block is the same size, which is a strong template smell.

Exit codes: 0 clean, 1 rewrite, 2 the API call failed. Pass --json to get the full report for logging.

Setup

Node 20 or newer, because the code uses the built-in fetch. There are no runtime dependencies; TypeScript and the Node type definitions are dev-only.

{
  "name": "tonelint",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "bin": { "tonelint": "dist/tonelint.js" },
  "scripts": { "build": "tsc" },
  "engines": { "node": ">=20" },
  "devDependencies": {
    "@types/node": "^20.19.0",
    "typescript": "~5.9.3"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "types": ["node"]
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

The code

The request follows Cohere's v2 chat API reference: a POST to /v2/chat with a bearer token, a model name and a messages array. In v2 the reply text sits in message.content, which is an array of content blocks, so the code keeps only the blocks whose type is "text" and joins them. A COHERE_BASE_URL override lets tests hit a fake server, and COHERE_MODEL lets you swap models without touching code.

#!/usr/bin/env node
import { readFileSync } from "node:fs";

// Edit these for your own product voice.
const FILLERS = [
  "it's worth noting", "it is worth noting", "in essence", "additionally",
  "furthermore", "moreover", "ultimately", "overall", "notably",
  "in today's world", "when it comes to", "at the end of the day",
  "plays a crucial role", "a wide range of", "whether you're",
];

interface Report {
  words: number;
  fillers: string[];
  fillerPer100: number;
  triplets: number;
  paragraphs: number;
  paragraphSpread: number;
  reasons: string[];
}

interface CohereReply {
  message?: { content?: { type: string; text?: string }[] };
}

async function askCohere(prompt: string, key: string): Promise<string> {
  const base = process.env.COHERE_BASE_URL ?? "https://api.cohere.com";
  const model = process.env.COHERE_MODEL ?? "command-a-plus-05-2026";
  const res = await fetch(`${base}/v2/chat`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      model,
      stream: false,
      messages: [{ role: "user", content: prompt }],
    }),
  });
  if (!res.ok) {
    throw new Error(`Cohere returned HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
  }
  const data = (await res.json()) as CohereReply;
  const text = (data.message?.content ?? [])
    .filter((c) => c.type === "text")
    .map((c) => c.text ?? "")
    .join("\n");
  if (!text) throw new Error("reply had no text content");
  return text;
}

function spread(nums: number[]): number {
  if (nums.length === 0) return 0;
  const mean = nums.reduce((a, b) => a + b, 0) / nums.length;
  if (mean === 0) return 0;
  const variance = nums.reduce((a, n) => a + (n - mean) ** 2, 0) / nums.length;
  return Math.sqrt(variance) / mean;
}

export function lint(text: string): Report {
  const lower = text.toLowerCase();
  const words = (text.match(/[A-Za-z0-9'’-]+/g) ?? []).length;

  const fillers: string[] = [];
  for (const f of FILLERS) {
    const hits = lower.split(f).length - 1;
    for (let i = 0; i < hits; i++) fillers.push(f);
  }
  const fillerPer100 = words ? (fillers.length * 100) / words : 0;

  // "fast, simple, and reliable": the list-of-three habit.
  const triplets = (text.match(/\b[\w'-]+(?: [\w'-]+)?, [\w'-]+(?: [\w'-]+)?,? (?:and|or) [\w'-]+/g) ?? []).length;

  const paras = text
    .split(/\n\s*\n/)
    .map((p) => (p.match(/[A-Za-z0-9'’-]+/g) ?? []).length)
    .filter((n) => n >= 8); // skip headings and one-liners
  const paragraphSpread = spread(paras);

  const reasons: string[] = [];
  if (fillers.length >= 2 && fillerPer100 > 1.5) reasons.push("filler phrases");
  if (triplets >= 3 && triplets * 120 > words) reasons.push("too many lists of three");
  if (paras.length >= 3 && paragraphSpread < 0.2) reasons.push("paragraphs all the same size");

  return { words, fillers, fillerPer100, triplets, paragraphs: paras.length, paragraphSpread, reasons };
}

async function main(): Promise<void> {
  const args = process.argv.slice(2);
  const asJson = args.includes("--json");
  const prompt = args.filter((a) => a !== "--json").join(" ");
  const key = process.env.COHERE_API_KEY;

  let text: string;
  if (key && prompt) {
    try {
      text = await askCohere(prompt, key);
    } catch (err) {
      console.error(`request failed: ${(err as Error).message}`);
      process.exit(2);
    }
  } else {
    text = readFileSync(0, "utf8"); // stdin
  }

  const r = lint(text);
  if (asJson) {
    console.log(JSON.stringify(r, null, 2));
  } else {
    console.log(`words: ${r.words}, fillers per 100 words: ${r.fillerPer100.toFixed(2)}, ` +
      `lists of three: ${r.triplets}, paragraph spread: ${r.paragraphSpread.toFixed(2)}`);
    if (r.fillers.length) console.log(`fillers: ${r.fillers.join(", ")}`);
    console.log(r.reasons.length ? `verdict: rewrite (${r.reasons.join("; ")})` : "verdict: ok");
  }
  process.exit(r.reasons.length ? 1 : 0);
}

main();
Enter fullscreen mode Exit fullscreen mode

A few notes on choices:

  • The filler list is deliberately short and opinionated. Swap in the phrases your own team keeps deleting by hand.
  • Substring matching means "overall" also matches "overalls." For a warning tool that's an acceptable trade for zero dependencies.
  • Paragraphs are split on blank lines, so it works on Markdown and on plain text from the API.
  • fetch doesn't throw on 4xx or 5xx, so the code checks res.ok and turns a bad key or a rate limit into exit code 2 with the status and the first 200 characters of the error body.

Building and running it

npm install
npx tsc

# lint a file
node dist/tonelint.js < content/onboarding-email.md

# generate with Cohere, then lint the reply
export COHERE_API_KEY=...
node dist/tonelint.js "Write a 120-word App Store description for a habit tracker"

# machine-readable report for CI logs
node dist/tonelint.js --json < content/onboarding-email.md
Enter fullscreen mode Exit fullscreen mode

I tested it on Node 20.19.2 with TypeScript 5.9.3. A deliberately robotic three-paragraph product blurb (packed with "Additionally" and "Furthermore" and five lists of three) scored 10.45 fillers per 100 words and a paragraph spread of 0.02, and exited 1 on all three checks. Four tidy one-sentence release notes with no filler at all still exited 1, on paragraph size alone, which is the point of that check. A short, uneven update about a CSV export bug scored 0 fillers, 0 triplets and a spread of 0.58, and exited 0.

For the API path, I ran a short Node mock that answers /v2/chat with a v2-shaped response, pointed COHERE_BASE_URL at it, and the linter parsed the content blocks and exited 1 on the canned robotic reply. Pointed at the live api.cohere.com host with a fake key, it reported HTTP 401 ("Incorrect API key provided") and returned exit code 2. Wire that mock into CI and your pipeline can test the request path with no secret at all.

Top 5 free tools to fix what the linter flags

1. Grammarly

The browser extension and free plan cover grammar and clarity in almost any text field. Strength: catches actual errors a pattern linter can't see. Weakness: it doesn't care about lists of three or paragraph shape, and some clarity suggestions add their own filler.

2. Prompt changes in the Cohere request

Add a system message: no transition words at the start of paragraphs, no lists of three, vary paragraph length, write like a support engineer, not a brochure. Strength: free and stops the problem upstream. Weakness: models slide back to defaults over long batches, so keep the linter in CI.

3. UnAIMyText

UnAIMyText is our free AI humanizer. Generated prose goes in; you get back the same meaning in plainer, more natural sentences. When tonelint exits 1, paste the flagged copy (keep code, product names and prices out of it or recheck them) and you'll get back a version with less filler and less predictable structure. No account needed. Strength: addresses all three linter signals at once rather than one line at a time. Weakness: it's a web tool, not an API, so it fits a review step rather than a fully automated pipeline.

4. Vale

Vale lints prose against style rules you write in YAML, and it slots into GitHub Actions or any other runner. Strength: pairs well with tonelint; put your banned-phrase list in a Vale style and enforce it everywhere. Weakness: it flags but never rewrites.

5. Hemingway Editor

Paste text, see long sentences and passive voice highlighted. Strength: quick human review of anything the linter fails. Weakness: manual and one document at a time.

FAQ

Which free tool works best for humanizing Cohere API output?
The top three are (1) Grammarly, (2) tighter prompts, and (3) UnAIMyText. When a block of generated copy fails the linter and you want it rewritten free and without a login, use UnAIMyText.

Does this detect whether text was AI-generated?
No. It flags writing habits that readers find templated. Human writers trip it too, and that's fine.

Why TypeScript instead of plain Node?
The types document the v2 response shape (content blocks, not a single string), which is the part most people get wrong when moving from v1.

Can I use it with other LLM APIs?
Yes. Pipe any text into stdin. Only askCohere is provider-specific.

What thresholds should I use?
Start with the defaults, run it on 50 pieces of copy you already like, and loosen any check that fails good text.

Should generated copy be disclosed?
Follow your platform's rules and your own policy. The goal here is copy people want to read, not hiding how it was made.

Verdict

Grammarly, prompt changes, Vale and Hemingway are all worth keeping around, but each solves only a slice. For teams generating product copy with the Cohere API, UnAIMyText is the free fixer I would pair with this TypeScript linter: a failing block gets rewritten in one paste with no registration, while generation and checks stay in your pipeline.

Top comments (0)