Friday, 19 December 2025

Build a “README & Docs Agent” Without Cursor/Copilot (Just .NET + an OpenAI Model)

 Are you or your team is lazy enough to for documentation then here is the article shows how to build a small DocAgent in C# that can scan any repo, detect the project type, and generate.

In this post you’ll build a small DocAgent: a CLI tool that scans a repo folder, understands what’s inside, and generates:

  • README.md
  • optional extra docs (e.g., docs/HOW_TO_RUN.mddocs/PROJECT_OVERVIEW.md)

We’ll do it using:

  • .NET (a simple console app)
  • the OpenAI Responses API via plain HttpClient (no special SDK required)

The goal is not “magic autonomy”. The goal is a reliable pipeline that produces accurate docs and says “TODO” when it can’t prove something.

Why an “agent” works better than a single prompt

If you paste an entire repo into a single prompt, you’ll hit token limits and accuracy issues.

A documentation agent should be a predictable workflow:

  1. Scan the repository and select the most relevant files
  2. Summarize those files (cheap model)
  3. Generate the README/docs from the summaries (better model)
  4. (Optional) Verify by running build/test commands, then regenerate

This is “agent-like” behavior with guardrails.

Make it “smarter”: detect the project language & app type first

One big upgrade is to detect the repo type before generating docs, so your agent can choose better templates:

  • .NETdotnet build/run/test*.csprojappsettings*.json
  • Nodepackage.json, lockfiles, npm|yarn|pnpm commands
  • Pythonpyproject.toml / requirements.txt, venv instructions

And within each stack:

DocAgent does this using lightweight heuristics based on repo marker files and (for Node) a quick read of package.json.

What we’re building

DocAgent is a console tool with these inputs:

  • --repo <path>: the repo you want to document (e.g., C:\git\myproject)
  • --readme <path>: where to write README.md (defaults to <repo>\README.md)
  • --docs <dir>: optional docs output directory (e.g., <repo>\docs)
  • --model and --summarize-model: choose models for generation vs summarization

And one required secret:

  • OPENAI_API_KEY environment variable (or --api-key)

Step 0: Create the tool project

Create a folder like:

DocAgent/

Create a console project:

dotnet new console -n DocAgent -o DocAgent

Target a modern framework (this example uses net10.0, but net8.0+ works fine too):

<TargetFramework>net10.0</TargetFramework>

Step 1: Design the repo scanner (the most important part)

Your tool needs to decide what files matter for documentation.

In general:

  • Include: *.csproj*.slnProgram.csREADME.mdDockerfileappsettings*.json, CI workflows
  • Exclude: bin/obj/.git/node_modules/, and binaries (*.dll*.exe)

A good scanner does two things:

  1. Filters out noise
  2. Ranks “high-signal” files first (project files, entrypoints, config)

In this repo, DocAgent uses a simple scoring heuristic and returns:

  • truncated file tree (for context)
  • list of high-signal file contents (limited to --max-files)

Step 2: Summarize files (cheap model)

Instead of sending raw files into the “write README” prompt, first summarize each file.

Why this helps:

  • cheaper and faster
  • reduces hallucinations (the model sees curated facts)
  • keeps the final generation prompt small

Prompt style that works well:

  • “Summarize this file for documentation”
  • “Do NOT guess”
  • “Output 5–12 bullets”
  • “Call out commands/config keys”

Use a smaller model here (ex: gpt-4.1-mini).

Step 3: Generate README/docs (better model)

Now you provide the model:

  • the repo tree (truncated)
  • the list of file summaries
  • a strict README template

And you enforce a rule:

If something isn’t in the repo, write TODO instead of guessing.

This is the single easiest way to prevent misleading docs.

Step 4: Call OpenAI using the Responses API (plain HttpClient)

DocAgent uses the OpenAI Responses API endpoint:

POST https://api.openai.com/v1/responses
Authorization: Bearer <your_key>

Why Responses API:

  • simple “give prompt, get text back”
  • works well for summarization and document generation

Your payload can be as simple as:

{
"model": "gpt-4.1-mini",
"input": [
{ "role": "system", "content": "..." },
{ "role": "user", "content": "..." }
]
}

Step 5: Wire it into a CLI

You need a command that anyone can run, like:

dotnet run --project DocAgent -- --repo "C:\git\myproject" --docs "C:\git\myproject\docs"

DocAgent also supports --dry-run so you can inspect the markdown without writing files.

“Agent” upgrades (optional, but powerful)

Once the basic pipeline works, these improvements add real quality:

Verification loop

  • Generate docs
  • Run dotnet build / dotnet test (or repo-specific commands)
  • Feed logs back to the model and ask it to fix inaccurate steps

RAG / embeddings

  • Index repo chunks once
  • Retrieve only relevant chunks for documentation tasks
  • Useful for monorepos or repeated doc requests

Doc types

  • Add --medium or --architecture modes to generate different outputs:
  • Medium article
  • Architecture overview
  • Onboarding guide
  • Runbook / operational guide

Key takeaways

  • A “documentation agent” is mostly a workflow: scan → summarize → generate.
  • The best guardrail is: don’t guess. Use TODO when unknown.
  • Use a cheaper model for summarization and a stronger model for final writing.

Azure OpenAI support (Responses vs Chat Completions)

Azure environments sometimes don’t expose the /responses route. To handle that, DocAgent supports:

  • --azure-mode responses (default): use /responses
  • --azure-mode chat: use /chat/completions
  • --azure-mode auto: try /responses, and if Azure returns 404, fall back to /chat/completions

The Azure API version is controlled by:

  • --azure-api-version 2024-12-01-preview

If you want, you can extend this idea to generate:

  • API docs from controllers
  • ADRs (architecture decision records)
  • release notes from git history

Appendix: Where to find the implementation in this code repo

  • DocAgent/RepoScanner.cs: file filtering, ranking, and tree building
  • DocAgent/ProjectDetector.cs: detect .NET vs Node vs Python (and web vs non-web)
  • DocAgent/DocumentationGenerator.cs: summarize → generate README → generate extra docs
  • DocAgent/OpenAiResponsesClient.cs: Responses API client via HttpClient
  • DocAgent/Program.cs: CLI args + wiring

Full copy/paste implementation (all DocAgent files)

If you want readers to recreate this agent without cloning anything, here is the entire DocAgent/ project file-by-file.

DocAgent/DocAgent.csproj

<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

DocAgent/Program.cs

using System.Text;
using DocAgent;
var parsed = Args.Parse(args);
if (parsed.ShowHelp)
{
Console.WriteLine(Args.HelpText);
return 0;
}
var repoRoot = Path.GetFullPath(parsed.RepoRoot ?? Directory.GetCurrentDirectory());
var readmePath = Path.GetFullPath(parsed.ReadmePath ?? Path.Combine(repoRoot, "README.md"));
var docsPath = parsed.DocsDirectory is null ? null : Path.GetFullPath(parsed.DocsDirectory);
var apiKey = parsed.ApiKey ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Missing API key. Pass --api-key or set OPENAI_API_KEY.");
Console.Error.WriteLine("Run with --help for usage.");
return 2;
}
var apiBase = parsed.ApiBase ?? "https://api.openai.com/v1";
var model = parsed.Model ?? "gpt-4.1";
var summarizeModel = parsed.SummarizeModel ?? "gpt-4.1-mini";
var maxFiles = parsed.MaxFiles ?? 80;
var azureApiVersion = parsed.AzureApiVersion ?? "2024-12-01-preview";
var azureMode = parsed.AzureMode ?? "responses";
if (!Enum.TryParse<AzureMode>(azureMode, ignoreCase: true, out var parsedAzureMode))
{
Console.Error.WriteLine($"Invalid --azure-mode '{azureMode}'. Expected: responses|chat|auto.");
return 2;
}
// Detect project type early so users understand what the agent is doing.
var repoFiles = RepoScanner.ListRepoFiles(repoRoot);
var detected = ProjectDetector.Detect(repoRoot, repoFiles);
Console.WriteLine($"Detected: {detected.Language} / {detected.Kind} (confidence: {detected.Confidence})");
if (parsed.ExplainDetection)
{
foreach (var e in detected.Evidence) Console.WriteLine($"- {e}");
}
var http = new HttpClient();
var openai = new OpenAiResponsesClient(http, apiBase, apiKey, azureApiVersion, parsedAzureMode);
var generator = new DocumentationGenerator(openai);
var result = await generator.GenerateAsync(new GenerateRequest(
RepoRoot: repoRoot,
ReadmePath: readmePath,
DocsDirectory: docsPath,
ReadmeModel: model,
SummarizeModel: summarizeModel,
MaxFiles: maxFiles
));
if (parsed.DryRun)
{
Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine(result.ReadmeMarkdown);
if (result.AdditionalDocs.Count > 0)
{
Console.WriteLine();
Console.WriteLine("---- Additional docs ----");
foreach (var doc in result.AdditionalDocs)
{
Console.WriteLine();
Console.WriteLine($"# {doc.RelativePath}");
Console.WriteLine(doc.Markdown);
}
}
return 0;
}
Directory.CreateDirectory(Path.GetDirectoryName(readmePath)!);
await File.WriteAllTextAsync(readmePath, result.ReadmeMarkdown, Encoding.UTF8);
Console.WriteLine($"Wrote {readmePath}");
if (docsPath is not null)
{
Directory.CreateDirectory(docsPath);
foreach (var doc in result.AdditionalDocs)
{
var outPath = Path.Combine(docsPath, doc.RelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(outPath)!);
await File.WriteAllTextAsync(outPath, doc.Markdown, Encoding.UTF8);
Console.WriteLine($"Wrote {outPath}");
}
}
return 0;
static class Args
{
public static readonly string HelpText = """
DocAgent - generate README/docs for a repository using OpenAI.
Usage:
dotnet run --project DocAgent -- [options]
Options:
--repo <path> Repo/project root to document (default: current directory)
--readme <path> Output README path (default: <repo>/README.md)
--docs <dir> Also write docs into this directory (e.g. <repo>/docs)
--api-key <key> OpenAI API key (default: OPENAI_API_KEY env var)
--api-base <url> API base URL (default: https://api.openai.com/v1)
--azure-api-version <ver> Azure OpenAI api-version (used only for *.openai.azure.com; default: 2024-12-01-preview)
--azure-mode <mode> Azure routing: responses|chat|auto (default: responses; auto falls back to chat on 404)
--model <name> Model for README/docs generation (default: gpt-4.1)
--summarize-model <name> Model for file summarization (default: gpt-4.1-mini)
--max-files <n> Max files to summarize (default: 80)
--dry-run Print markdown to stdout (don't write files)
--explain-detection Print detection evidence (why the agent thinks it's .NET/Node/Python and web/non-web)
--help Show help
"""
;
public static ParsedArgs Parse(string[] args)
{
var p = new ParsedArgs();
for (var i = 0; i < args.Length; i++)
{
var a = args[i];
if (a is "--help" or "-h" or "/?")
{
p.ShowHelp = true;
continue;
}
if (a == "--dry-run")
{
p.DryRun = true;
continue;
}
if (a == "--explain-detection")
{
p.ExplainDetection = true;
continue;
}
string? next = i + 1 < args.Length ? args[i + 1] : null;
if (a == "--repo" && next is not null) { p.RepoRoot = next; i++; continue; }
if (a == "--readme" && next is not null) { p.ReadmePath = next; i++; continue; }
if (a == "--docs" && next is not null) { p.DocsDirectory = next; i++; continue; }
if (a == "--api-key" && next is not null) { p.ApiKey = next; i++; continue; }
if (a == "--api-base" && next is not null) { p.ApiBase = next; i++; continue; }
if (a == "--azure-api-version" && next is not null) { p.AzureApiVersion = next; i++; continue; }
if (a == "--azure-mode" && next is not null) { p.AzureMode = next; i++; continue; }
if (a == "--model" && next is not null) { p.Model = next; i++; continue; }
if (a == "--summarize-model" && next is not null) { p.SummarizeModel = next; i++; continue; }
if (a == "--max-files" && next is not null && int.TryParse(next, out var n)) { p.MaxFiles = n; i++; continue; }
// Unknown arg -> show help (safer than silently ignoring).
p.ShowHelp = true;
}
return p;
}
public sealed class ParsedArgs
{
public bool ShowHelp { get; set; }
public bool DryRun { get; set; }
public bool ExplainDetection { get; set; }
public string? RepoRoot { get; set; }
public string? ReadmePath { get; set; }
public string? DocsDirectory { get; set; }
public string? ApiKey { get; set; }
public string? ApiBase { get; set; }
public string? AzureApiVersion { get; set; }
public string? AzureMode { get; set; }
public string? Model { get; set; }
public string? SummarizeModel { get; set; }
public int? MaxFiles { get; set; }
}
}

DocAgent/ProjectDetector.cs

using System.Text.Json;
namespace DocAgent;
public enum PrimaryLanguage
{
Unknown = 0,
DotNet,
Node,
Python
}
public enum AppKind
{
Unknown = 0,
ConsoleOrLibrary,
WebApp
}
public sealed record DetectedProject(
PrimaryLanguage Language,
AppKind Kind,
string Confidence,
IReadOnlyList<string> Evidence
)
;
public static class ProjectDetector
{
public static DetectedProject Detect(string repoRoot, IReadOnlyList<string> repoFiles)
{
var evidence = new List<string>();
// -------- .NET --------
var csproj = repoFiles.FirstOrDefault(p => p.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase));
if (csproj is not null)
{
evidence.Add($"Found C# project file: {csproj}");
var kind = AppKind.ConsoleOrLibrary;
try
{
var full = Path.Combine(repoRoot, csproj);
var xml = File.ReadAllText(full);
if (xml.Contains("Microsoft.NET.Sdk.Web", StringComparison.OrdinalIgnoreCase))
{
kind = AppKind.WebApp;
evidence.Add("csproj uses Microsoft.NET.Sdk.Web (ASP.NET Core Web app).");
}
else if (repoFiles.Any(p => p.Replace('\\', '/').Contains("/Controllers/", StringComparison.OrdinalIgnoreCase)))
{
kind = AppKind.WebApp;
evidence.Add("Found Controllers/ folder (likely ASP.NET Web API).");
}
}
catch
{
// ignore, we still detected .NET
}
return new DetectedProject(PrimaryLanguage.DotNet, kind, "high", evidence);
}
// -------- Node --------
if (repoFiles.Any(p => Path.GetFileName(p).Equals("package.json", StringComparison.OrdinalIgnoreCase)))
{
evidence.Add("Found package.json (Node project).");
var kind = AppKind.ConsoleOrLibrary;
var pkgPath = repoFiles.First(p => Path.GetFileName(p).Equals("package.json", StringComparison.OrdinalIgnoreCase));
try
{
var full = Path.Combine(repoRoot, pkgPath);
using var doc = JsonDocument.Parse(File.ReadAllText(full));
var root = doc.RootElement;
// Heuristics for web apps
if (repoFiles.Any(p => Path.GetFileName(p).Equals("next.config.js", StringComparison.OrdinalIgnoreCase) ||
Path.GetFileName(p).Equals("next.config.mjs", StringComparison.OrdinalIgnoreCase)))
{
kind = AppKind.WebApp;
evidence.Add("Found next.config.* (Next.js web app).");
}
else if (repoFiles.Any(p => p.Replace('\\', '/').Contains("/src/app/", StringComparison.OrdinalIgnoreCase) ||
p.Replace('\\', '/').Contains("/pages/", StringComparison.OrdinalIgnoreCase)))
{
kind = AppKind.WebApp;
evidence.Add("Found common web app folders (src/app or pages).");
}
if (root.TryGetProperty("dependencies", out var deps) && deps.ValueKind == JsonValueKind.Object)
{
var depText = deps.GetRawText();
if (depText.Contains("react", StringComparison.OrdinalIgnoreCase) ||
depText.Contains("next", StringComparison.OrdinalIgnoreCase) ||
depText.Contains("express", StringComparison.OrdinalIgnoreCase) ||
depText.Contains("fastify", StringComparison.OrdinalIgnoreCase))
{
kind = AppKind.WebApp;
evidence.Add("package.json dependencies indicate a web framework (react/next/express/fastify).");
}
}
}
catch
{
// ignore
}
return new DetectedProject(PrimaryLanguage.Node, kind, "medium", evidence);
}
// -------- Python --------
var hasPyproject = repoFiles.Any(p => Path.GetFileName(p).Equals("pyproject.toml", StringComparison.OrdinalIgnoreCase));
var hasRequirements = repoFiles.Any(p => Path.GetFileName(p).Equals("requirements.txt", StringComparison.OrdinalIgnoreCase));
var hasSetupPy = repoFiles.Any(p => Path.GetFileName(p).Equals("setup.py", StringComparison.OrdinalIgnoreCase));
if (hasPyproject || hasRequirements || hasSetupPy)
{
if (hasPyproject) evidence.Add("Found pyproject.toml (Python project).");
if (hasRequirements) evidence.Add("Found requirements.txt (Python project).");
if (hasSetupPy) evidence.Add("Found setup.py (Python project).");
var kind = AppKind.ConsoleOrLibrary;
if (repoFiles.Any(p => Path.GetFileName(p).Equals("manage.py", StringComparison.OrdinalIgnoreCase)))
{
kind = AppKind.WebApp;
evidence.Add("Found manage.py (likely Django web app).");
}
else if (repoFiles.Any(p => p.EndsWith("app.py", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith("wsgi.py", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith("asgi.py", StringComparison.OrdinalIgnoreCase)))
{
kind = AppKind.WebApp;
evidence.Add("Found common Python web entrypoints (app.py/wsgi.py/asgi.py).");
}
return new DetectedProject(PrimaryLanguage.Python, kind, "medium", evidence);
}
return new DetectedProject(PrimaryLanguage.Unknown, AppKind.Unknown, "low", ["No strong project markers found."]);
}
}

DocAgent/RepoScanner.cs

using System.Text;
namespace DocAgent;
public sealed record ScannedFile(string RelativePath, long SizeBytes, string Content);
public sealed record ScannedRepo(string RepoRoot, string Tree, IReadOnlyList<ScannedFile> Files);
public sealed record FileSummary(string RelativePath, string Summary);
public static class RepoScanner
{
private static readonly HashSet<string> IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase)
{
".git", ".vs", "bin", "obj", "node_modules", "packages", ".idea"
};
private static readonly string[] HighSignalExtensions =
[
".csproj", ".sln", ".slnx",
".cs", ".fs", ".vb",
".py",
".js", ".mjs", ".cjs", ".ts", ".tsx",
".md",
".json", ".yml", ".yaml", ".toml",
".props", ".targets", ".ini", ".env",
".ps1", ".sh", ".cmd", ".bat"
];
private static readonly string[] HighSignalNames =
[
"Program.cs",
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
"global.json",
"README.md", "LICENSE",
// Node
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
// Python
"pyproject.toml", "requirements.txt", "Pipfile", "Pipfile.lock", "poetry.lock", "setup.py", "setup.cfg",
// Web
".env", ".env.example"
];
/// <summary>
/// Returns relative paths of all "candidate" files in the repo (excluding ignored directories and obvious binaries).
/// Used for project detection and heuristics.
/// </summary>
public static IReadOnlyList<string> ListRepoFiles(string repoRoot, int maxFiles = 10_000)
{
repoRoot = Path.GetFullPath(repoRoot);
var list = new List<string>();
foreach (var file in EnumerateFiles(repoRoot))
{
list.Add(Path.GetRelativePath(repoRoot, file));
if (list.Count >= maxFiles) break;
}
return list;
}
public static ScannedRepo Scan(string repoRoot, int maxFiles)
{
repoRoot = Path.GetFullPath(repoRoot);
var allFiles = EnumerateFiles(repoRoot).ToList();
// Rank high-signal files first, then fall back to other source/config files.
var ranked = allFiles
.Select(p => new { Path = p, Score = ScoreFile(repoRoot, p) })
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Path.Length)
.Take(maxFiles)
.Select(x => x.Path)
.ToList();
var scanned = new List<ScannedFile>();
foreach (var file in ranked)
{
var rel = Path.GetRelativePath(repoRoot, file);
var fi = new FileInfo(file);
var content = ReadTextBestEffort(file, maxChars: 18_000);
scanned.Add(new ScannedFile(rel, fi.Length, content));
}
return new ScannedRepo(repoRoot, BuildTree(repoRoot, maxEntries: 250), scanned);
}
private static IEnumerable<string> EnumerateFiles(string repoRoot)
{
var stack = new Stack<string>();
stack.Push(repoRoot);
while (stack.Count > 0)
{
var dir = stack.Pop();
foreach (var subDir in Directory.EnumerateDirectories(dir))
{
var name = Path.GetFileName(subDir);
if (IgnoredDirectories.Contains(name)) continue;
stack.Push(subDir);
}
foreach (var file in Directory.EnumerateFiles(dir))
{
if (IsIgnoredFile(file)) continue;
yield return file;
}
}
}
private static bool IsIgnoredFile(string file)
{
var name = Path.GetFileName(file);
// Avoid binary / generated / lock files that add little value.
if (name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return true;
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) return true;
if (name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)) return true;
if (name.EndsWith(".cache", StringComparison.OrdinalIgnoreCase)) return true;
if (name.EndsWith(".user", StringComparison.OrdinalIgnoreCase)) return true;
if (name.EndsWith(".suo", StringComparison.OrdinalIgnoreCase)) return true;
// Prefer "source-like" content.
var ext = Path.GetExtension(file);
if (HighSignalExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase)) return false;
// Allow some special names even if extension isn't in our list.
if (HighSignalNames.Contains(name, StringComparer.OrdinalIgnoreCase)) return false;
return true;
}
private static int ScoreFile(string repoRoot, string path)
{
var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
var name = Path.GetFileName(path);
var ext = Path.GetExtension(path);
var score = 0;
if (HighSignalNames.Contains(name, StringComparer.OrdinalIgnoreCase)) score += 200;
if (name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) score += 180;
if (name.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase)) score += 170;
if (name.Equals("Program.cs", StringComparison.OrdinalIgnoreCase)) score += 140;
if (name.StartsWith("appsettings", StringComparison.OrdinalIgnoreCase)) score += 120;
if (rel.StartsWith(".github/workflows/", StringComparison.OrdinalIgnoreCase)) score += 110;
if (rel.Contains("/docker", StringComparison.OrdinalIgnoreCase) || name.StartsWith("docker", StringComparison.OrdinalIgnoreCase)) score += 90;
if (ext.Equals(".md", StringComparison.OrdinalIgnoreCase)) score += 60;
if (ext.Equals(".cs", StringComparison.OrdinalIgnoreCase)) score += 50;
if (ext.Equals(".json", StringComparison.OrdinalIgnoreCase) || ext.Equals(".yml", StringComparison.OrdinalIgnoreCase) || ext.Equals(".yaml", StringComparison.OrdinalIgnoreCase)) score += 40;
// Slightly prefer top-level files (often docs/config).
score += Math.Max(0, 15 - rel.Count(c => c == '/'));
return score;
}
private static string ReadTextBestEffort(string file, int maxChars)
{
try
{
var text = File.ReadAllText(file, Encoding.UTF8);
return text.Length <= maxChars ? text : text[..maxChars] + "\n\n... (truncated)\n";
}
catch
{
try
{
var text = File.ReadAllText(file);
return text.Length <= maxChars ? text : text[..maxChars] + "\n\n... (truncated)\n";
}
catch
{
return "<unreadable>";
}
}
}
private static string BuildTree(string repoRoot, int maxEntries)
{
var sb = new StringBuilder();
var entries = 0;
foreach (var file in EnumerateFiles(repoRoot).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
var rel = Path.GetRelativePath(repoRoot, file).Replace('\\', '/');
sb.AppendLine(rel);
entries++;
if (entries >= maxEntries)
{
sb.AppendLine("... (tree truncated)");
break;
}
}
return sb.ToString();
}
}

DocAgent/DocumentationGenerator.cs

using System.Text;
namespace DocAgent;
public sealed record GenerateRequest(
string RepoRoot,
string ReadmePath,
string? DocsDirectory,
string ReadmeModel,
string SummarizeModel,
int MaxFiles
);
public sealed record GeneratedDoc(string RelativePath, string Markdown);
public sealed record GenerateResult(string ReadmeMarkdown, IReadOnlyList<GeneratedDoc> AdditionalDocs);
public sealed class DocumentationGenerator
{
private readonly OpenAiResponsesClient _openai;
public DocumentationGenerator(OpenAiResponsesClient openai)
{
_openai = openai;
}
public async Task<GenerateResult> GenerateAsync(GenerateRequest req, CancellationToken ct = default)
{
var repoFiles = RepoScanner.ListRepoFiles(req.RepoRoot);
var detected = ProjectDetector.Detect(req.RepoRoot, repoFiles);
var repo = RepoScanner.Scan(req.RepoRoot, req.MaxFiles);
var summaries = await SummarizeFilesAsync(repo, req.SummarizeModel, ct);
var readme = await GenerateReadmeAsync(detected, repo, summaries, req.ReadmeModel, ct);
var docs = new List<GeneratedDoc>();
if (req.DocsDirectory is not null)
{
docs.Add(new GeneratedDoc("PROJECT_OVERVIEW.md", await GenerateProjectOverviewAsync(detected, repo, summaries, req.ReadmeModel, ct)));
docs.Add(new GeneratedDoc("HOW_TO_RUN.md", await GenerateHowToRunAsync(detected, repo, summaries, req.ReadmeModel, ct)));
}
return new GenerateResult(readme, docs);
}
private async Task<IReadOnlyList<FileSummary>> SummarizeFilesAsync(ScannedRepo repo, string model, CancellationToken ct)
{
var list = new List<FileSummary>();
foreach (var f in repo.Files)
{
var system = "You are a senior engineer summarizing repository files for documentation. Be accurate and concrete.";
var user = $"""
Summarize this file for documentation purposes.
Rules:
- Focus on what a reader needs for README/docs: what it does, how it's used, key commands/config, gotchas.
- Keep it short (5-12 bullets). Include relevant command examples if present.
- If the file is clearly irrelevant to running/understanding the project, say so.
FILE: {f.RelativePath}
SIZE_BYTES: {f.SizeBytes}
CONTENT:
{f.Content}
"
"";
var summary = await _openai.CreateResponseTextAsync(model, system, user, ct);
list.Add(new FileSummary(f.RelativePath, summary.Trim()));
}
return list;
}
private async Task<string> GenerateReadmeAsync(DetectedProject detected, ScannedRepo repo, IReadOnlyList<FileSummary> summaries, string model, CancellationToken ct)
{
var system = """
You write accurate READMEs for software repos.
Do NOT guess. If info is missing, add a TODO section instead of hallucinating.
Prefer actionable steps and concrete commands.
Return ONLY markdown.
"
"";
var stackGuidance = BuildStackGuidance(detected);
var user = $"""
Create a README.md for this repository.
Detected project (heuristic):
- Language: {detected.Language}
- Kind: {detected.Kind}
- Confidence: {detected.Confidence}
- Evidence:
{string.Join("
\n", detected.Evidence.Select(e => $" - {e}"))}
Repository root: {repo.RepoRoot}
Repository tree (truncated):
{repo.Tree}
File summaries:
{FormatSummaries(summaries)}
README requirements:
- Title + one-paragraph overview
- Features (bullets)
- Prerequisites
- Quickstart (commands)
- Configuration (env vars / config files if present)
- Project structure (top-level overview)
- Troubleshooting (common build/run issues)
- If a license file exists in the tree, mention it; otherwise omit.
- Add a TODO section ONLY when necessary (unknown ports/env vars/etc).
Stack-specific guidance (follow if applicable; otherwise use TODO):
{stackGuidance}
Important: Keep it friendly and easy for a beginner to run.
"
"";
return (await _openai.CreateResponseTextAsync(model, system, user, ct)).Trim() + "\n";
}
private async Task<string> GenerateProjectOverviewAsync(DetectedProject detected, ScannedRepo repo, IReadOnlyList<FileSummary> summaries, string model, CancellationToken ct)
{
var system = """
You write an easy-to-understand project overview document.
Do NOT guess; call out unknowns as TODO.
Return ONLY markdown.
"
"";
var user = $"""
Write docs/PROJECT_OVERVIEW.md for this repository.
Detected project:
- Language: {detected.Language}
- Kind: {detected.Kind}
- Confidence: {detected.Confidence}
Include:
- What problem it solves
- What's inside (major components/files)
- Key flows / entrypoints
- How to extend it safely
Repo tree:
{repo.Tree}
Summaries:
{FormatSummaries(summaries)}
"
"";
return (await _openai.CreateResponseTextAsync(model, system, user, ct)).Trim() + "\n";
}
private async Task<string> GenerateHowToRunAsync(DetectedProject detected, ScannedRepo repo, IReadOnlyList<FileSummary> summaries, string model, CancellationToken ct)
{
var system = """
You write a concise runbook for developers.
Do NOT guess. Prefer exact dotnet/npm/etc commands found in the repo summaries.
Return ONLY markdown.
"
"";
var stackGuidance = BuildStackGuidance(detected);
var user = $"""
Write docs/HOW_TO_RUN.md for this repository.
Include:
- Prerequisites
- Build
- Run
- Test (if applicable)
- Common issues and fixes
Detected project:
- Language: {detected.Language}
- Kind: {detected.Kind}
- Confidence: {detected.Confidence}
Stack-specific guidance:
{stackGuidance}
Repo tree:
{repo.Tree}
Summaries:
{FormatSummaries(summaries)}
"
"";
return (await _openai.CreateResponseTextAsync(model, system, user, ct)).Trim() + "\n";
}
private static string BuildStackGuidance(DetectedProject detected)
{
// This is *guidance*, not facts. The model must still rely on repo evidence and use TODO if uncertain.
return detected.Language switch
{
PrimaryLanguage.DotNet when detected.Kind == AppKind.WebApp => """
- Prefer ASP.NET Core style instructions.
- Typical commands (use only if confirmed by repo): `dotnet restore`, `dotnet build`, `dotnet run`, `dotnet test`.
- Mention environment configuration: `appsettings*.json`, `launchSettings.json`, and common env vars (if present).
- If hosting/ports are unclear, add TODO rather than guessing.
"
"",
PrimaryLanguage.DotNet => """
- Prefer .NET style instructions.
- Typical commands (use only if confirmed by repo): `dotnet restore`, `dotnet build`, `dotnet run`, `dotnet test`.
- Mention TargetFramework and how to run from repo root.
"
"",
PrimaryLanguage.Node when detected.Kind == AppKind.WebApp => """
- Prefer Node web app instructions.
- Detect package manager from lockfile: `pnpm-lock.yaml` => pnpm, `yarn.lock` => yarn, `package-lock.json` => npm.
- Typical commands (use only if confirmed by repo): install deps, run dev server, build, test.
- Mention `.env` / `.env.example` if present. If port is unclear, add TODO.
"
"",
PrimaryLanguage.Node => """
- Prefer Node app/library instructions.
- Detect package manager from lockfile: `pnpm-lock.yaml` => pnpm, `yarn.lock` => yarn, `package-lock.json` => npm.
- Typical commands (use only if confirmed by repo): install, build, test.
"
"",
PrimaryLanguage.Python when detected.Kind == AppKind.WebApp => """
- Prefer Python web app instructions.
- Mention creating a venv and installing deps (`requirements.txt` or `pyproject.toml` tooling) if present.
- If Django/Flask/FastAPI is detected from files, tailor commands accordingly; otherwise use TODO.
"
"",
PrimaryLanguage.Python => """
- Prefer Python app/library instructions.
- Mention creating a venv and installing deps (`requirements.txt` or `pyproject.toml` tooling) if present.
"
"",
_ => """
- Language/framework is unclear: keep instructions generic and add TODOs for missing run commands.
"
""
};
}
private static string FormatSummaries(IReadOnlyList<FileSummary> summaries)
{
var sb = new StringBuilder();
foreach (var s in summaries)
{
sb.AppendLine($"### {s.RelativePath}");
sb.AppendLine(s.Summary);
sb.AppendLine();
}
return sb.ToString();
}
}

DocAgent/OpenAiResponsesClient.cs

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace DocAgent;
public enum AzureMode
{
Responses,
Chat,
Auto
}
public sealed class OpenAiHttpException : Exception
{
public int StatusCode { get; }
public string Url { get; }
public string ResponseBody { get; }
public OpenAiHttpException(int statusCode, string url, string responseBody)
: base($"OpenAI error ({statusCode}) calling {url}: {responseBody}")

{
StatusCode = statusCode;
Url = url;
ResponseBody = responseBody;
}
}
/// <summary>
/// Minimal client for OpenAI "Responses API" via HttpClient.
/// Keeps dependencies small and works even without a dedicated SDK.
/// </summary>
public sealed class OpenAiResponsesClient
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _apiKey;
private readonly string _azureApiVersion;
private readonly AzureMode _azureMode;
public OpenAiResponsesClient(HttpClient http, string baseUrl, string apiKey, string? azureApiVersion = null, AzureMode azureMode = AzureMode.Responses)
{
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_apiKey = apiKey;
_azureApiVersion = string.IsNullOrWhiteSpace(azureApiVersion) ? "2024-12-01-preview" : azureApiVersion;
_azureMode = azureMode;
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<string> CreateResponseTextAsync(string model, string systemPrompt, string userPrompt, CancellationToken ct = default)
{
var isAzure = _baseUrl.Contains("openai.azure.com", StringComparison.OrdinalIgnoreCase);
if (!isAzure)
{
var payload = new
{
model,
input = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var url = $"{_baseUrl}/responses";
var json = await SendJsonAsync(url, payload, isAzure: false, ct);
return ExtractOutputText(json);
}
// Azure: treat "model" as deployment name.
return _azureMode switch
{
AzureMode.Chat => await AzureChatAsync(model, systemPrompt, userPrompt, ct),
AzureMode.Responses => await AzureResponsesAsync(model, systemPrompt, userPrompt, ct),
AzureMode.Auto => await AzureAutoAsync(model, systemPrompt, userPrompt, ct),
_ => throw new InvalidOperationException($"Invalid Azure mode: {_azureMode}")
};
}
private async Task<string> AzureAutoAsync(string deployment, string systemPrompt, string userPrompt, CancellationToken ct)
{
try
{
return await AzureResponsesAsync(deployment, systemPrompt, userPrompt, ct);
}
catch (OpenAiHttpException ex) when (ex.StatusCode == 404)
{
return await AzureChatAsync(deployment, systemPrompt, userPrompt, ct);
}
}
private async Task<string> AzureResponsesAsync(string deployment, string systemPrompt, string userPrompt, CancellationToken ct)
{
var payload = new
{
input = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var url = $"{_baseUrl}/openai/deployments/{deployment}/responses?api-version={_azureApiVersion}";
var json = await SendJsonAsync(url, payload, isAzure: true, ct);
return ExtractOutputText(json);
}
private async Task<string> AzureChatAsync(string deployment, string systemPrompt, string userPrompt, CancellationToken ct)
{
var payload = new
{
messages = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var url = $"{_baseUrl}/openai/deployments/{deployment}/chat/completions?api-version={_azureApiVersion}";
var json = await SendJsonAsync(url, payload, isAzure: true, ct);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("choices", out var choices) &&
choices.ValueKind == JsonValueKind.Array &&
choices.GetArrayLength() > 0)
{
var choice0 = choices[0];
if (choice0.TryGetProperty("message", out var msg) &&
msg.ValueKind == JsonValueKind.Object &&
msg.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.String)
{
return content.GetString() ?? "";
}
}
throw new InvalidOperationException("Could not extract message content from Azure chat completion response.");
}
private async Task<string> SendJsonAsync(string url, object payload, bool isAzure, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
if (isAzure)
{
req.Headers.Remove("api-key");
req.Headers.Add("api-key", _apiKey);
}
else
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
}
using var resp = await _http.SendAsync(req, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new OpenAiHttpException((int)resp.StatusCode, url, json);
return json;
}
/// <summary>
/// Extract the "concatenated text output" from a Responses API payload.
/// We intentionally keep it tolerant to minor schema changes.
/// </summary>
private static string ExtractOutputText(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Common shape: output: [ { content: [ { type:"output_text", text:"..." }, ... ] }, ... ]
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
foreach (var item in output.EnumerateArray())
{
if (!item.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
continue;
foreach (var c in content.EnumerateArray())
{
if (c.TryGetProperty("type", out var type) &&
type.ValueKind == JsonValueKind.String &&
type.GetString() == "output_text" &&
c.TryGetProperty("text", out var text) &&
text.ValueKind == JsonValueKind.String)
{
sb.Append(text.GetString());
}
}
}
var t = sb.ToString();
if (!string.IsNullOrWhiteSpace(t)) return t;
}
// Fallback: some payloads expose output_text directly
if (root.TryGetProperty("output_text", out var outputText) && outputText.ValueKind == JsonValueKind.String)
return outputText.GetString() ?? "";
throw new InvalidOperationException("Could not extract output text from OpenAI response.");
}
}

How to Run

From the repository root:

dotnet build

Set your API key:

$env:OPENAI_API_KEY="YOUR_KEY_HERE"

Generate a README for the current repo (writes ./README.md):

dotnet run --project DocAgent -- --repo . --readme ./README.md

Generate README + extra docs (writes ./README.md and ./docs/*):

dotnet run --project DocAgent -- --repo . --readme ./README.md --docs ./docs

Dry-run (prints markdown to stdout instead of writing files):

dotnet run --project DocAgent -- --repo . --dry-run

Explain detection (prints why the agent thinks it’s .NET/Node/Python and web/non-web):

dotnet run --project DocAgent -- --repo . --explain-detection --dry-run

Azure OpenAI notes

If --api-base looks like https://<resource>.openai.azure.com/, DocAgent treats --model and --summarize-model as deployment names.

Azure routing can be controlled with:

  • --azure-mode responses (default): use /responses
  • --azure-mode chat: use /chat/completions
  • --azure-mode auto: try /responses, and if Azure returns 404, fall back to /chat/completions

Example:

dotnet run --project DocAgent -- --repo . ^
--api-base "https://<resource>.openai.azure.com/" ^
--api-key "%AZURE_OPENAI_API_KEY%" ^
--azure-api-version "2024-12-01-preview" ^
--azure-mode auto ^
--model "<deployment-name>" ^
--summarize-model "<summarize-deployment-name>"

Notes / Safety

  • The tool intentionally says TODO when information can’t be proven from the repo contents.
  • Binary/generated folders like bin/obj/.git/node_modules/ are ignored.