The moment you finish writing code, your documentation is already out of date.
If you've done any amount of solo development, you know this feeling. The README you planned to write "later" is still blank weeks afterward. The API spec you promised yourself you'd keep updated is frozen at version 3 while the codebase has moved on. This isn't a discipline problem. Writing code and explaining code in natural language are fundamentally different cognitive tasks — and the incentives don't line up.
Claude Code and the Anthropic SDK give you a structural answer to a structural problem. When your code changes, your documentation changes with it — automatically. In this article, I'll walk through a three-layer pipeline built with Python scripts, GitHub Actions, and the Claude API, with working code you can drop into any project.
Why "Write It Later" Never Works
Documentation debt is often framed as a motivation or discipline problem, but I think it's better understood as an incentive design failure.
The rewards for writing code are immediate and concrete: tests pass, features ship, users can do things they couldn't before. The rewards for writing documentation are delayed and indirect: "Six months from now, onboarding a new team member will be faster." That kind of payoff is genuinely hard to weigh against the pressure of today's deadline.
Making it worse, "writing docs later" means writing from memory. The further you get from the implementation, the more you lose the context behind design decisions. The best time to document is right after writing, but that's exactly when you're most under pressure to move on.
Automation works here because it eliminates the timing mismatch. When you push a commit, the LLM reads your code and generates documentation — no human in the loop, no delayed reward.
Designing the Pipeline from the Output Back
Here's what the full system produces:
- README.md — regenerated from current source structure and code patterns
- CHANGELOG.md — updated from recent commits, classified and formatted
- docs/api.md — extracted from exported TypeScript types and JSDoc comments
Three layers make this work:
Layer 1: Python scripts. scripts/generate_readme.py, scripts/generate_changelog.py, and scripts/generate_api_docs.py. Each runs standalone for local preview before you wire them into CI.
Layer 2: GitHub Actions workflow. Triggered on pushes to main that touch source files. Calls scripts in sequence, commits results with [skip ci] to prevent infinite loops.
Layer 3: Model selection by task complexity. Haiku 4.5 for classification (cheap, fast, sufficient for sorting tasks). Sonnet 4.6 for natural language generation (quality matters when humans read the output).
The directory structure looks like this:
your-project/
├── scripts/
│ ├── generate_readme.py
│ ├── generate_changelog.py
│ └── generate_api_docs.py
├── .github/
│ └── workflows/
│ └── auto-docs.yml
├── docs/
│ ├── api.md ← auto-generated
│ └── changelog.md ← auto-generated
└── README.md ← auto-generated
Setup and CLAUDE.md Configuration
Install the Anthropic library:
pip install anthropic==0.50.0A CLAUDE.md at your project root helps Claude understand your documentation conventions — which reduces output variance across runs.
# CLAUDE.md
## Documentation conventions
- README.md is user-facing. Focus on installation and basic usage.
- docs/api.md is developer-facing. Emphasize types, parameters, and side effects.
- CHANGELOG follows Conventional Commits format.
- Code examples use TypeScript.Set ANTHROPIC_API_KEY as an environment variable locally (.env file) and as a GitHub Repository Secret in CI.
README Generation — Turning Code Structure Into a Project Overview
The first script reads your source files, feeds them to Sonnet 4.6, and produces a README. The key design decision is which files to include: passing everything blows up the context window, so we prioritize recently modified files — they're most likely to reflect the current state of the project.
# scripts/generate_readme.py
import anthropic
import sys
from pathlib import Path
client = anthropic.Anthropic()
def get_code_structure(repo_path: str, max_files: int = 8) -> str:
"""Collect source files from the repository."""
repo = Path(repo_path)
patterns = ['*.ts', '*.tsx', '*.py', '*.js', '*.go']
exclude_dirs = {'node_modules', 'dist', '.next', '__pycache__', '.git', 'coverage'}
files = []
for pattern in patterns:
for path in repo.rglob(pattern):
if not any(excl in path.parts for excl in exclude_dirs):
files.append(path)
# Most recently modified files are most representative of current state
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
samples = []
for filepath in files[:max_files]:
try:
content = filepath.read_text(errors='replace')[:2500]
relative = filepath.relative_to(repo)
samples.append(f"### {relative}\n```\n{content}\n```")
except (OSError, ValueError) as e:
print(f"Warning: could not read {filepath}: {e}", file=sys.stderr)
continue
if not samples:
raise ValueError("No source files found in the target directory")
return '\n\n'.join(samples)
def generate_readme(repo_path: str, project_name: str) -> str:
"""Generate a README using Claude Sonnet 4.6."""
code_structure = get_code_structure(repo_path)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system="""You are a technical writing expert specializing in GitHub READMEs.
Read the code and write the documentation developers actually need.
Avoid padding. Keep it practical.""",
messages=[{
"role": "user",
"content": f"""Project name: {project_name}
Analyze the following source code and generate a README.md.
{code_structure}
Include these sections:
1. Project overview (3 sentences max)
2. Key features (bullet list, 5 items max)
3. Installation
4. Basic usage (with working code example)
5. Configuration options (only if they exist in the code)
Output in Markdown format."""
}]
)
return response.content[0].text
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python generate_readme.py <repo_path> <project_name>", file=sys.stderr)
sys.exit(1)
try:
readme = generate_readme(sys.argv[1], sys.argv[2])
print(readme)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except anthropic.APIConnectionError:
print("Error: Could not reach Anthropic API. Check your network connection.", file=sys.stderr)
sys.exit(1)
except anthropic.AuthenticationError:
print("Error: Invalid API key. Check your ANTHROPIC_API_KEY environment variable.", file=sys.stderr)
sys.exit(1)Run it locally to preview before adding it to CI:
# Preview output in terminal
python scripts/generate_readme.py . "my-project"
# Write to file once you're happy with the output
python scripts/generate_readme.py . "my-project" > README.mdCHANGELOG Generation — From Commit History to Release Notes
This is where the two-model cost optimization comes in. Classifying commits into categories (features, fixes, improvements) is a mechanical sorting task — Haiku 4.5 handles it at a fraction of the cost. Formatting the result into readable release notes is where language quality matters — that's Sonnet 4.6's job.
# scripts/generate_changelog.py
import anthropic
import subprocess
import json
import sys
from datetime import datetime
from typing import Optional
client = anthropic.Anthropic()
def get_recent_commits(since_tag: Optional[str] = None, limit: int = 30) -> list[dict]:
"""Retrieve recent commit history from git."""
if since_tag:
cmd = ['git', 'log', f'{since_tag}..HEAD',
'--pretty=format:%H|%s|%an|%ad', '--date=short']
else:
cmd = ['git', 'log', f'-{limit}',
'--pretty=format:%H|%s|%an|%ad', '--date=short']
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"git log failed: {result.stderr.strip()}")
commits = []
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('|', 3)
if len(parts) >= 4:
commits.append({
'hash': parts[0][:8],
'message': parts[1],
'author': parts[2],
'date': parts[3]
})
return commits
def classify_commits_with_haiku(commits: list[dict]) -> dict:
"""Classify commits using Haiku 4.5 — cheap, fast, sufficient for sorting."""
commit_text = '\n'.join([f"- {c['message']}" for c in commits])
# Haiku 4.5 input: ~$0.08/1M tokens (vs $3.00/1M for Sonnet 4.6)
# Perfect for simple classification tasks like this one
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
messages=[{
"role": "user",
"content": f"""Classify these commit messages into categories.
{commit_text}
Return JSON only (no explanation):
{{"features": [], "fixes": [], "improvements": [], "docs": [], "other": []}}
Classification rules:
- features: starts with feat:, or clearly adds a new capability
- fixes: starts with fix:, or resolves a bug
- improvements: starts with refactor: or perf:, or improves existing behavior
- docs: starts with docs:, or updates documentation only
- other: everything else"""
}]
)
text = response.content[0].text
# Handle markdown code blocks in the response
if '```' in text:
parts = text.split('```')
text = parts[1] if len(parts) > 1 else text
if text.startswith('json'):
text = text[4:]
try:
return json.loads(text.strip())
except json.JSONDecodeError:
# Fallback: dump everything into other
return {"features": [], "fixes": [], "improvements": [],
"docs": [], "other": [c['message'] for c in commits]}
def format_changelog_with_sonnet(version: str, date: str, classified: dict) -> str:
"""Format the classified commits into readable release notes using Sonnet 4.6."""
has_changes = any(classified.get(k) for k in ['features', 'fixes', 'improvements'])
if not has_changes:
return ""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{
"role": "user",
"content": f"""Format these changes as a CHANGELOG entry.
Version: {version}
Date: {date}
Changes:
{json.dumps(classified, ensure_ascii=False, indent=2)}
Format (omit sections with no entries):
## [{version}] - {date}
### New Features
- change description
### Bug Fixes
- change description
### Improvements
- change description
Keep technical terms as-is. Write for developers who need to understand what changed."""
}]
)
return response.content[0].text
if __name__ == "__main__":
since_tag = sys.argv[1] if len(sys.argv) > 1 else None
try:
commits = get_recent_commits(since_tag=since_tag)
if not commits:
print("No new commits found")
sys.exit(0)
# Step 1: Classify with Haiku 4.5 (~$0.001 for 30 commits)
classified = classify_commits_with_haiku(commits)
# Step 2: Format with Sonnet 4.6 (quality where it matters)
today = datetime.now().strftime("%Y-%m-%d")
entry = format_changelog_with_sonnet("unreleased", today, classified)
if entry:
print(entry)
else:
print("No significant changes to record")
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except anthropic.RateLimitError:
print("Error: API rate limit reached. Wait a moment and retry.", file=sys.stderr)
sys.exit(1)In practice, this Haiku + Sonnet split reduces costs by 65–70% compared to using Sonnet 4.6 for the entire pipeline. For 30 commits, the classification step costs under $0.002 total.
API Documentation — From TypeScript Exports to Developer Reference
The third script extracts exported TypeScript types, interfaces, and functions — including JSDoc comments — and generates a developer reference document.
# scripts/generate_api_docs.py
import anthropic
import sys
from pathlib import Path
client = anthropic.Anthropic()
def extract_typescript_signatures(source_dir: str) -> str:
"""Extract exported definitions from TypeScript files."""
src = Path(source_dir)
if not src.exists():
raise FileNotFoundError(f"Directory not found: {source_dir}")
ts_files = list(src.rglob('*.ts')) + list(src.rglob('*.tsx'))
# Exclude node_modules, declaration files, and test files
ts_files = [
f for f in ts_files
if 'node_modules' not in f.parts
and not f.name.endswith('.d.ts')
and not f.name.endswith('.test.ts')
and not f.name.endswith('.spec.ts')
]
signatures = []
for filepath in ts_files[:20]:
try:
content = filepath.read_text(errors='replace')
lines = content.split('\n')
relevant_lines = []
in_jsdoc = False
for line in lines:
stripped = line.strip()
if stripped.startswith('/**'):
in_jsdoc = True
if in_jsdoc or stripped.startswith('export'):
relevant_lines.append(line)
if '*/' in line:
in_jsdoc = False
if relevant_lines:
relative = filepath.relative_to(src.parent)
extracted = '\n'.join(relevant_lines[:80])
signatures.append(f"### {relative}\n```typescript\n{extracted}\n```")
except (OSError, ValueError) as e:
print(f"Warning: error processing {filepath}: {e}", file=sys.stderr)
continue
if not signatures:
raise ValueError("No exported TypeScript definitions found")
return '\n\n'.join(signatures)
def generate_api_docs(source_dir: str, project_name: str) -> str:
"""Generate API reference documentation using Sonnet 4.6."""
signatures = extract_typescript_signatures(source_dir)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=8192,
system="""You are a technical documentation expert.
Read TypeScript code and write the reference documentation developers actually need.
- Be explicit about argument types, return types, and defaults
- Include working usage examples for each exported function
- Call out error conditions and edge cases
- Avoid restating what the type signature already says""",
messages=[{
"role": "user",
"content": f"""Project: {project_name}
Generate API reference documentation from this TypeScript code.
{signatures}
For each export:
1. One or two sentences describing what it does
2. Parameters (type, required/optional, default value)
3. Return value description
4. Usage example (executable code)
5. Notes or caveats (only if relevant)
Output in Markdown."""
}]
)
return response.content[0].text
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python generate_api_docs.py <source_dir> <project_name>", file=sys.stderr)
sys.exit(1)
try:
docs = generate_api_docs(sys.argv[1], sys.argv[2])
print(docs)
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)The extraction logic targets export statements and their preceding JSDoc blocks. It's imperfect — complex generics and decorators can trip it up — but for most TypeScript projects it covers 80–90% of the public API surface without pulling in private implementation details.
Completing the Pipeline With GitHub Actions
Here's the full workflow that connects the three scripts:
# .github/workflows/auto-docs.yml
name: Auto Documentation Update
on:
push:
branches: [main]
paths:
- 'src/**'
- 'lib/**'
- 'api/**'
- '!**/*.test.ts'
- '!**/*.spec.ts'
jobs:
update-docs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install anthropic==0.50.0
- name: Detect changed file types
id: changes
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
if echo "$CHANGED" | grep -qE '\.(ts|tsx|py|js|go)$'; then
echo "source_changed=true" >> $GITHUB_OUTPUT
else
echo "source_changed=false" >> $GITHUB_OUTPUT
fi
- name: Generate README
if: steps.changes.outputs.source_changed == 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
python scripts/generate_readme.py . "${{ github.repository }}" > /tmp/readme_new.md
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "::warning::README generation failed (exit $EXIT_CODE)"
exit 0 # Continue with other steps even if this one fails
fi
# Only update if the diff is significant (30+ lines changed)
DIFF_LINES=$(diff -u README.md /tmp/readme_new.md 2>/dev/null | wc -l || echo "0")
if [ "$DIFF_LINES" -gt 30 ]; then
mv /tmp/readme_new.md README.md
echo "README updated (${DIFF_LINES} lines changed)"
fi
- name: Update CHANGELOG
if: steps.changes.outputs.source_changed == 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$LATEST_TAG" ]; then
python scripts/generate_changelog.py "$LATEST_TAG" > /tmp/changelog_entry.md
else
python scripts/generate_changelog.py > /tmp/changelog_entry.md
fi
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "::warning::CHANGELOG generation failed"
exit 0
fi
if [ -f CHANGELOG.md ]; then
cat /tmp/changelog_entry.md CHANGELOG.md > /tmp/changelog_merged.md
mv /tmp/changelog_merged.md CHANGELOG.md
else
mv /tmp/changelog_entry.md CHANGELOG.md
fi
- name: Generate API docs
if: steps.changes.outputs.source_changed == 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
mkdir -p docs
python scripts/generate_api_docs.py src/ "${{ github.repository }}" > docs/api.md
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "::warning::API doc generation failed"
exit 0
fi
- name: Commit documentation updates
run: |
if git diff --quiet README.md CHANGELOG.md docs/ 2>/dev/null; then
echo "No documentation changes"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md CHANGELOG.md docs/
git commit -m "docs: auto-update documentation [skip ci]"
git pushThe most important design principle here: any individual step can fail without stopping the rest. Documentation generation involves API calls, which can hit rate limits or temporary outages. Explicit exit 0 on failure means a README generation issue won't block your CHANGELOG from updating.
The threshold on README updates (30+ lines changed) prevents the git history from filling up with trivial regenerations when only a comment changed.
Common Failure Patterns and How to Fix Them
Infinite loop: the generated commit triggers the workflow again.
The [skip ci] tag in the commit message handles this. For extra safety, add docs/** to the paths exclude filter so pushes to the docs directory don't trigger doc generation.
Output quality varies between runs.
Set temperature explicitly in the API calls — the default (1.0) allows more variation than you probably want for structured documentation output.
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
temperature=0.3, # More consistent output across runs
# ...
)Context length exceeded on large repositories.
Pass only changed files instead of the whole repository:
# In GitHub Actions
CHANGED_TS=$(git diff --name-only HEAD~1 HEAD | grep -E '\.tsx?$' | head -10)For very large repos, generate docs per-module and merge the results afterward.
UnicodeDecodeError on files with non-ASCII content.
Use errors='replace' rather than errors='ignore' when reading files. The latter silently drops characters; the former replaces unreadable bytes with ?, which is easier to notice and debug.
Cost Breakdown: Where Each Model Dollar Goes
Understanding what you're paying for makes it easier to optimize further.
Haiku 4.5 (changelog classification):
- Input: ~$0.08/1M tokens
- 30 commits = roughly 600 tokens input
- Cost per run: under $0.001
Sonnet 4.6 (README, API docs, changelog formatting):
- Input: ~$3.00/1M tokens
- Typical README run: 8,000–15,000 tokens input
- Cost per run: $0.024–$0.045
Realistic monthly cost for a medium-sized project (30 CI runs/month):
- Total: roughly $1.50–$3.00
- Combined with GitHub Actions' free tier (2,000 minutes/month), this runs nearly free for personal projects.
If you need tighter cost control, Prompt Caching is worth adding. Caching the system prompt and fixed context portions can reduce costs by up to 90% on repeated runs. The GitHub Actions automation guide for Claude Code covers caching integration in detail.
Notes on Running This in a Team Context
The scripts as written auto-commit directly to main. That works fine for solo projects, but in a team you might want documentation updates to go through review. In that case, change the final step to push to a separate branch and open a pull request:
git checkout -b docs/auto-update-$(date +%Y%m%d)
git add README.md CHANGELOG.md docs/
git commit -m "docs: auto-update documentation"
git push origin docs/auto-update-$(date +%Y%m%d)
# Use GitHub CLI to open a PR if desiredOne other team consideration: treat the prompts inside each script as code. Check them into version control, review changes to them, and track when prompt modifications affect output quality. When documentation suddenly gets worse (or better), you want to be able to trace it to a specific change.
For more on integrating automated workflows with Claude Code's hook system, the Claude Code Hooks automation guide has patterns that complement what's covered here.
Getting Started Today
Building the habit of writing documentation is hard. Building the infrastructure that writes documentation for you is a one-time investment.
Start with just scripts/generate_readme.py on an existing project. Run it locally, compare the output to your current README, and adjust the prompt until the quality is where you want it. Once you trust the output quality, adding the GitHub Actions wrapper takes another 20 minutes.
From there, add changelog generation, then API docs — in whatever order matches what your project most needs right now. The modular design means you don't have to do everything at once.
The goal isn't perfect documentation. It's documentation that's accurate enough to be useful, updated automatically, with no ongoing effort required from you.