DEV Community

UnAIMyText
UnAIMyText

Posted on

Humanize Perplexity Agent API Output in C#: A .NET 10 Linter for Orphan Citations and Source Talk + 5 Free Tools (2026)

Tested and written in November 2026. Fair warning: my team builds UnAIMyText, the third entry in the tool list. Copy the C# freely.

Our support team sends a monthly "what changed" email to customers: new plan prices, shipping cutoffs, policy updates. Last quarter we started drafting the background sections with Perplexity, because its answers come back grounded in web search and cite their sources.

That was the feature and the problem. Drafts arrived with [1][2][3][4] stacked after sentences, the occasional [7] when there were only five sources, and lines like "Based on the search results, the new rate applies from March." One draft went out with a dangling marker, and a customer asked where footnote 7 was.

So I wrote a C# linter that runs before a draft reaches the email tool.

What it checks

Earlier linters in this series checked wording, punctuation and Markdown. This one checks citations:

  1. Orphan markers. A [7] with no search result whose id is 7. One is enough to fail.
  2. Citation stacks. More than three markers in a row after one claim.
  3. Source talk. "According to the search results," "based on the provided sources," "the sources indicate." Nobody reading a customer email should see these.

Exit code 0 means pass, 1 means rewrite and 2 means the request failed. --json prints a report, and --strip prints the text with the markers removed when it passes, ready to paste.

What the docs say about the response

Perplexity's Agent API quickstart describes POST https://api.perplexity.ai/v1/agent (with /v1/responses accepted as an alias). Three details shaped the parser:

  • Without an SDK you read the answer from the output array: items of "type": "message", then their content entries of "type": "output_text". There's no choices[0].message.content; that belongs to the legacy Sonar format.
  • Web search is opt-in. A request with only a model and input answers from the model's own knowledge with no citations, so the linter sends an effort level.
  • In the quickstart's example response, one of the search results has no id. The parser skips those rather than crashing.

The migration guide from Sonar covers the citations. The fast effort level uses numbered markers like [1], while low, medium and high use source-typed ones like [web:1], so the regex accepts both. The markers sit in the answer text, and each [n] maps to the result whose id is n in the search_results output item. The guide's sample instructions ask the model to cite "the one to three sources that most directly support the statement" and not to invent source numbers, which is where my stack limit of three and the orphan check came from.

The code

This needs the .NET 10 SDK, which runs a single .cs file directly, with no project file or NuGet package. The #:property lines turn on nullable checks and make every warning an error.

#:property Nullable=enable
#:property TreatWarningsAsErrors=true
// BriefLint: lint a Perplexity Agent API answer before it goes into a newsletter.
// dotnet run BriefLint.cs -- "question"      (calls POST /v1/agent)
// dotnet run BriefLint.cs < answer.md       (plain text or a saved response JSON)
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;

const int MaxStack = 3;          // [1][2][3] is fine, a fourth marker is not
const int MaxSourceTalk = 0;     // "according to the search results" never belongs in a newsletter

var json = args.Contains("--json");
var strip = args.Contains("--strip");
var prompt = string.Join(' ', args.Where(a => a is not ("--json" or "--strip")));

string text;
HashSet<int>? ids = null;
try
{
    if (prompt.Length > 0)
    {
        (text, ids) = Parse(await AskAgent(prompt));
    }
    else
    {
        var raw = Console.In.ReadToEnd();
        (text, ids) = raw.TrimStart().StartsWith('{') ? Parse(raw) : (raw, null);
    }
}
catch (Exception e) when (e is HttpRequestException or JsonException or InvalidOperationException
                             or TaskCanceledException)
{
    Console.Error.WriteLine($"error: {e.Message}");
    return 2;
}

var report = Lint(text, ids);
if (json) Console.WriteLine(ToJson(report));
else
{
    Console.WriteLine($"markers {report.Markers}, orphans [{string.Join(", ", report.Orphans)}], " +
                      $"longest stack {report.LongestStack}, source talk {report.SourceTalk.Count}");
    foreach (var r in report.Reasons) Console.WriteLine($"FAIL {r}");
    if (report.Reasons.Count == 0) Console.WriteLine("PASS");
}
if (strip && report.Reasons.Count == 0) Console.WriteLine(Citation().Replace(text, ""));
return report.Reasons.Count == 0 ? 0 : 1;

static async Task<string> AskAgent(string prompt)
{
    var key = Environment.GetEnvironmentVariable("PERPLEXITY_API_KEY")
              ?? throw new InvalidOperationException("PERPLEXITY_API_KEY is not set");
    var baseUrl = (Environment.GetEnvironmentVariable("PERPLEXITY_BASE_URL") ?? "https://api.perplexity.ai/v1").TrimEnd('/');
    var body = new JsonObject
    {
        ["effort"] = Environment.GetEnvironmentVariable("PERPLEXITY_EFFORT") ?? "fast",
        ["instructions"] = "Write for customers in plain sentences. Cite sources as [n] right after the claim.",
        ["input"] = prompt,
    };
    using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
    using var req = new HttpRequestMessage(HttpMethod.Post, baseUrl + "/agent")
    {
        Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
    };
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
    using var res = await http.SendAsync(req);
    var raw = await res.Content.ReadAsStringAsync();
    if (!res.IsSuccessStatusCode)
        throw new HttpRequestException($"HTTP {(int)res.StatusCode}: {raw[..Math.Min(raw.Length, 200)]}");
    return raw;
}

// The answer lives in output items of type "message"; sources in an item of type "search_results".
static (string, HashSet<int>) Parse(string raw)
{
    var root = JsonNode.Parse(raw) ?? throw new JsonException("empty response");
    if (root["error"] is JsonNode err) throw new InvalidOperationException($"API error: {err.ToJsonString()}");
    var status = root["status"]?.GetValue<string>();
    if (status != "completed")
        Console.Error.WriteLine($"warning: status={status ?? "missing"} {root["incomplete_details"]?.ToJsonString()}");

    var text = new StringBuilder();
    var ids = new HashSet<int>();
    foreach (var item in root["output"]?.AsArray() ?? [])
    {
        var type = item?["type"]?.GetValue<string>();
        if (type == "message")
        {
            foreach (var part in item!["content"]?.AsArray() ?? [])
                if (part?["type"]?.GetValue<string>() == "output_text")
                    text.Append(part["text"]?.GetValue<string>());
        }
        else if (type == "search_results")
        {
            // A result without an id can't be cited, so it is skipped.
            foreach (var r in item!["results"]?.AsArray() ?? [])
                if (r?["id"] is JsonNode id) ids.Add(id.GetValue<int>());
        }
    }
    if (string.IsNullOrWhiteSpace(text.ToString()))
        throw new InvalidOperationException("no output_text in response");
    return (text.ToString(), ids);
}

static Report Lint(string text, HashSet<int>? ids)
{
    var markers = Citation().Matches(text);
    var orphans = ids is null ? [] : markers.Select(m => int.Parse(m.Groups[1].Value))
                                            .Where(n => !ids.Contains(n)).Distinct().Order().ToList();
    var longest = Stack().Matches(text).Select(m => Citation().Matches(m.Value).Count).DefaultIfEmpty(0).Max();
    var talk = SourceTalk().Matches(text).Select(m => m.Value).ToList();

    var reasons = new List<string>();
    if (orphans.Count > 0) reasons.Add($"citation markers with no matching search result: {string.Join(", ", orphans)}");
    if (longest > MaxStack) reasons.Add($"citation stack of {longest} markers");
    if (talk.Count > MaxSourceTalk) reasons.Add($"source talk: \"{talk[0]}\"");
    return new Report(markers.Count, orphans, longest, talk, reasons);
}

static string ToJson(Report r)
{
    var o = new JsonObject
    {
        ["markers"] = r.Markers,
        ["orphans"] = new JsonArray(r.Orphans.Select(n => (JsonNode)n).ToArray()),
        ["longest_stack"] = r.LongestStack,
        ["source_talk"] = new JsonArray(r.SourceTalk.Select(s => (JsonNode)s).ToArray()),
        ["pass"] = r.Reasons.Count == 0,
    };
    return o.ToJsonString();
}

record Report(int Markers, List<int> Orphans, int LongestStack, List<string> SourceTalk, List<string> Reasons);

partial class Program
{
    // [1] from the fast effort level, [web:1] from low, medium and high.
    [GeneratedRegex(@"\[(?:web:)?(\d+)\]")] private static partial Regex Citation();
    [GeneratedRegex(@"(?:\[(?:web:)?\d+\]\s?)+")] private static partial Regex Stack();
    [GeneratedRegex(@"\b(?:according to (?:the |my )?(?:search results|sources|provided results)|based on (?:the )?(?:search results|provided sources|available sources|sources provided)|the (?:search results|sources) (?:show|indicate|suggest|mention))\b",
        RegexOptions.IgnoreCase)]
    private static partial Regex SourceTalk();
}
Enter fullscreen mode Exit fullscreen mode

The regexes use [GeneratedRegex], and the report is built with JsonObject rather than reflection-based serialization, so the build stays warning-free.

A mock Agent API

HttpListener is enough for a local mock. Its robotic mode returns an orphan, a four-marker stack and two source-talk phrases; clean returns a passing answer. Every mode puts bait in a search result snippet (source talk and a stack) that the linter must ignore, includes a result without an id, and logs the effort level and whether an auth header arrived.

#:property Nullable=enable
#:property TreatWarningsAsErrors=true
// dotnet run MockAgent.cs -- 8091 robotic|clean|incomplete|error
using System.Net;
using System.Text;
using System.Text.Json.Nodes;

var port = args.Length > 0 ? args[0] : "8091";
var mode = args.Length > 1 ? args[1] : "robotic";
var answer = mode == "robotic"
    ? "Based on the search results, the new plan launches in March[1][2][3][4]. " +
      "According to the sources, existing customers keep their price for a year[7]."
    : "The new plan launches in March[1]. Existing customers keep their current price for a year[2].";

var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
listener.Start();
Console.Error.WriteLine($"mock listening on {port} ({mode})");
while (true)
{
    var ctx = await listener.GetContextAsync();
    var req = await new StreamReader(ctx.Request.InputStream).ReadToEndAsync();
    var sent = JsonNode.Parse(req);
    Console.Error.WriteLine($"mock got {ctx.Request.HttpMethod} {ctx.Request.Url?.AbsolutePath} " +
                            $"effort={sent?["effort"]} auth={ctx.Request.Headers["Authorization"] is not null}");
    var res = new JsonObject
    {
        ["id"] = "resp_mock",
        ["object"] = "response",
        ["status"] = mode == "incomplete" ? "incomplete" : "completed",
        ["error"] = mode == "error" ? new JsonObject { ["message"] = "mock failure" } : null,
        ["incomplete_details"] = mode == "incomplete" ? new JsonObject { ["reason"] = "max_output_tokens" } : null,
        ["output"] = new JsonArray(
            new JsonObject
            {
                ["type"] = "search_results",
                ["results"] = new JsonArray(
                    // Bait: source talk inside a snippet must not count.
                    new JsonObject { ["id"] = 1, ["title"] = "Pricing", ["snippet"] = "According to the search results, [9][9][9][9]" },
                    new JsonObject { ["id"] = 2, ["title"] = "Plan FAQ", ["snippet"] = "Existing customers..." },
                    new JsonObject { ["title"] = "No id", ["snippet"] = "skipped" }),
            },
            new JsonObject
            {
                ["type"] = "message",
                ["role"] = "assistant",
                ["status"] = "completed",
                ["content"] = new JsonArray(new JsonObject { ["type"] = "output_text", ["text"] = answer }),
            }),
    };
    var status = ctx.Request.Url?.AbsolutePath == "/agent" ? 200 : 404;
    var bytes = Encoding.UTF8.GetBytes(status == 200 ? res.ToJsonString() : "{\"error\":\"not found\"}");
    ctx.Response.StatusCode = status;
    ctx.Response.ContentType = "application/json";
    await ctx.Response.OutputStream.WriteAsync(bytes);
    ctx.Response.Close();
}
Enter fullscreen mode Exit fullscreen mode

Running it

# lint a draft or a saved API response
dotnet run BriefLint.cs < drafts/march-update.md
dotnet run BriefLint.cs --json < saved/response.json

# ask the Agent API, then lint
export PERPLEXITY_API_KEY=...
dotnet run BriefLint.cs -- "When do the new UK shipping rates start?"

# against the mock
dotnet run MockAgent.cs -- 8091 robotic &
PERPLEXITY_API_KEY=test PERPLEXITY_BASE_URL=http://127.0.0.1:8091 dotnet run BriefLint.cs -- "anything"
Enter fullscreen mode Exit fullscreen mode

PERPLEXITY_EFFORT changes the effort level and PERPLEXITY_BASE_URL points it at the mock.

Test results

Both files built on .NET SDK 10.0.401 with 0 warnings and 0 errors, with TreatWarningsAsErrors on.

From stdin: a robotic draft with a four-marker stack and two source-talk phrases exited 1 on both checks. A [web:n] draft with four stacked markers exited 1. A plain draft exited 0, and --strip printed it with the markers removed. A saved response citing [3] when only results 1 and 2 existed exited 1 with "orphans":[3].

Against the mock, the robotic answer failed all three checks (orphans 3, 4 and 7, a stack of four, source talk). The clean answer passed even though a snippet contained source talk and a stack of [9]s. The incomplete mode printed warning: status=incomplete {"reason":"max_output_tokens"} and still linted the text. A top-level error object exited 2. The mock log showed effort=fast and an auth header on every request.

The real endpoint with a dummy key returned HTTP 401 invalid_api_key, and the linter exited 2. A missing key and a closed port both exited 2 as well. No live key was used, so it hasn't seen a real answer yet.

5 free tools for fixing a draft that fails

1. Vale

Vale is a prose linter you configure with YAML rules. Strength: add your own banned phrases, like "according to the search results," and run it on every draft. Weakness: it knows nothing about which source ids exist.

2. A tighter instructions field

Tell the model your rules directly: plain sentences, at most three citations per claim, never mention search results. Strength: free, and fixes it upstream. Weakness: it reduces the habit without guaranteeing it's gone.

3. UnAIMyText

UnAIMyText is a free AI humanizer tuned for machine-sounding drafts; paste one in and you get natural, human wording back with the meaning kept. Run --strip first, then paste the marker-free draft that still sounds like a search summary. No account needed. Strength: takes the "research assistant" voice out of a whole section in one pass. Weakness: with no public API, it sits in the manual editing pass rather than the scheduled job; recheck dates, prices and names after.

4. LanguageTool

LanguageTool checks grammar and style, free, across dozens of languages. Strength: handy when the update is translated. Weakness: long drafts may need splitting on the free plan.

5. Hemingway Editor

The free web editor marks long, dense sentences. Strength: shows where a draft packs three claims into one sentence. Weakness: it won't touch the citations.

FAQ

What is the best free AI humanizer for Perplexity API output?
My order: (1) Vale for custom phrase rules, (2) a stricter instructions field, (3) UnAIMyText. When a flagged draft has to read like a person wrote it, UnAIMyText is my pick, at no cost and without an account.

Is BriefLint an AI detector?
Not at all. It checks citation hygiene and a few phrases, and plenty of human drafts would fail it too.

Why not use an SDK?
The quickstart's SDK examples are Python and TypeScript. In C#, one HttpClient call and some JsonNode parsing were enough.

Will it work with other providers?
Stdin mode lints any text. Only AskAgent and Parse know Perplexity's response shape.

Can I change the limits?
MaxStack and MaxSourceTalk are at the top of the file, and the phrase list is the last regex.

Verdict

Vale, a stricter instructions field, LanguageTool and Hemingway all help. For .NET developers drafting customer-facing copy with Perplexity's Agent API, UnAIMyText is the free companion I'd keep next to this linter: a failed draft gets humanized in one paste without an account, while the API call and the CI gate stay in your C# code.

Top comments (0)