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:
- Orphan markers. A
[7]with no search result whose id is 7. One is enough to fail. - Citation stacks. More than three markers in a row after one claim.
- 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
outputarray: items of"type": "message", then their content entries of"type": "output_text". There's nochoices[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
effortlevel. - 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(); } 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(); } 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 ...


