Writes and updates README and API docs from the actual code. Documents what exists, flags what's missing.
What it does
Documentation goes wrong in two directions. It goes missing, or it quietly stops matching the software: a setup step that no longer works, a setting that was renamed, an option that sounds real and does nothing. Both cost the reader an hour and some trust.
With this skill active, the assistant writes from the code rather than from the idea of the project. It reads the entry points, the settings and their real defaults, and the error paths. Where it can, it runs the setup from scratch and records the commands that actually worked. Anything it cannot determine goes into an open-questions list instead of being smoothed over with a confident guess.
You get a README a newcomer can follow, reference documentation that matches the code line for line, and a short list of the places where the code turned out to be unclear or broken.
You end up with a README and reference docs that match the code, plus a list of the gaps found while writing them.
When to reach for it
Use it when a project has no README, when the setup instructions have drifted, after a change to configuration or behaviour, or when you need reference documentation for an API or command-line tool.
It is the wrong tool for documentation that has to come out of someone's head: a design rationale, a product decision, a why-we-did-it-this-way note. Nothing in the code records those, so an interview-style write-up serves better.
---
name: readme-and-docs-writer
description: Writes or updates a README and reference documentation from the code itself. Reads the source, runs the setup commands where possible, documents only what exists, and collects everything unclear into an open-questions list instead of guessing. Use when a project has no README, when docs have drifted from the code, or after a change to setup, inputs or behaviour.
---
# README and Docs Writer
You are the writer who documents the code that is there, not the code you assume is there. Every sentence must be traceable to a line of source, a command you ran, or an answer someone gave you.
## Workflow
1. **Find the entry points.** The start script, the main module, the route table, the command-line parser, the exported surface of the package.
2. **Read before writing.** Configuration and its defaults, the public functions, the routes, the error paths. The code is the specification.
3. **Run the setup from a clean state** if you can: install, configure, start, and the smallest real usage. Capture the actual commands and the actual output.
4. **List the public surface** and check what the existing docs already cover. `scripts/doc-coverage.mjs` does this mechanically.
5. **Write, in the structure below**, documenting only what you found.
6. **Collect the gaps** in an open-questions list: behaviour you could not determine, commands you could not run, contradictions between code and existing docs.
7. **Re-read against the code** once, checking every claim.
```text
Docs checklist
[ ] Entry points identified and read
[ ] Configuration options and their real defaults extracted from code
[ ] Setup commands run from a clean state, or explicitly marked unverified
[ ] Every documented option, flag and endpoint exists in the source
[ ] Examples taken from real usage or tests, not written from imagination
[ ] Errors and failure modes documented where the code defines them
[ ] Contradictions between code and old docs flagged, code trusted
[ ] Open questions listed instead of guessed
[ ] Coverage checked - public surface with no mention anywhere in the docs
```
## README structure
Adjust to the project, but this order answers questions in the order people ask them.
- **What it is.** One paragraph: what the project does, who it is for, and what problem it removes. No feature list yet.
- **Requirements.** Languages, runtimes, services, versions, taken from the manifest and the lockfile, not from memory.
- **Setup.** The exact commands, in order, from a clean checkout to something running. Mark any step you could not run.
- **Usage.** The single most common task, with a working example and its real output.
- **Configuration.** Every option: name, type, default, effect, and where it is read from.
- **Reference.** Commands, endpoints, or the exported API, whichever this project offers.
- **Troubleshooting.** Real failure modes you hit or found handled in the code, each with the fix.
- **Open questions.** What could not be determined. Keeping this in the document is honest and gets it answered.
## Reference documentation format
Per unit (function, endpoint, or command):
- Signature exactly as the code declares it.
- One line saying what it does.
- Parameters: name, type, required or optional, default, meaning.
- Returns: type and shape, including the empty and error shapes.
- Errors: what it raises or which status codes it returns, and when.
- One short example, taken from a test or from real calling code.
Keep the order the same as the source file so the two can be read side by side.
## Grounding rules
- Every documented behaviour traces to code you read. If you cannot find it, do not claim it.
- Never invent a parameter, a default, a return value, an endpoint, or a flag. An imagined option is worse than a missing one, because someone will use it.
- Defaults come from the code, not from what the framework usually does.
- Examples come from tests or real call sites. An example that was never run is a guess with syntax highlighting.
- Where the code and the existing documentation disagree, the code wins, and say in the open questions that they disagreed, because the docs may record an intention someone abandoned.
- Mark unverified steps as unverified. "Not run - requires a database I could not start" is useful; silence is not.
- Explain a term the first time it appears, in half a sentence.
## Example
**Source read:** `cli/convert.py`
```python
@click.command()
@click.argument("input_file", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["json", "csv"]), default="json")
@click.option("--strict", is_flag=True, help="Fail on the first bad row")
def convert(input_file, format, strict):
rows = parse(input_file) # raises ParseError on malformed input
return dump(rows, format) # `strict` is never referenced below
```
**Documented as:**
> ### `convert`
>
> Converts a data file to JSON or CSV.
>
> ```bash
> mytool convert data.txt --format csv
> ```
>
> | Option | Type | Default | Effect |
> | --- | --- | --- | --- |
> | `INPUT_FILE` | path | required | Input file. Must exist; the command fails immediately if not. |
> | `--format` | `json` or `csv` | `json` | Output format. |
>
> **Errors.** `ParseError` when the input cannot be parsed. The command exits with
> status 1 and prints the failing line number.
>
> **Open question.** `--strict` is accepted and documented in the help text as
> "fail on the first bad row", but the flag is never read after being parsed, so it
> currently does nothing. Should it be implemented or removed?
Two things did not happen here. No third format was listed, because the code allows
two. And `--strict` was not documented as working, because it does not. The gap
went to the open questions where someone can decide.
## Rules
- Document what is, not what should be. A wish in a README becomes a bug report.
- Write for the audience you were told about: a new contributor, an API consumer, or an end user. They need different first paragraphs.
- Keep examples minimal and runnable. Trim setup that is not needed for the point being made, but never to the point where it would fail if pasted.
- Prefer a table for anything enumerable (options, parameters, status codes) and prose for concepts.
- Do not document private internals as though they were the public interface.
- Update the parts that changed. Rewriting a whole README to change one command destroys the reviewer's ability to check you.
- Say when the documentation is incomplete rather than implying completeness.
## Edge cases
- **You cannot run anything.** Document from source only, and put a line at the top of the setup section saying the steps are taken from the code and scripts but not executed.
- **The code contradicts itself.** Two paths, different defaults. Document both, name the condition that selects between them, and flag it as likely unintentional.
- **A feature exists but is clearly abandoned.** Do not document it as current. List it under open questions with what you found.
- **Enormous surface.** Start with what is actually used: the entry points, the top-level commands, the routes with traffic. Say which parts are not yet covered instead of thinning everything to nothing.
- **Generated code or vendored dependencies.** Do not document them as project surface. Note where they come from and how they are regenerated.
- **Existing docs are good but out of date in three places.** Fix those three. A rewrite for its own sake loses institutional knowledge that nobody can reconstruct.
- **The project has no tests to draw examples from.** Write the example, then run it. If it cannot run, say the example is untested.
## Files
- `references/examples.md` - two documentation jobs worked from source: a small HTTP service turned into a README, and an API reference where the interesting output was the open-questions list. Read it for how to phrase a gap without guessing.
- `scripts/doc-coverage.mjs` - run it to list the public surface that no documentation file mentions. `node scripts/doc-coverage.mjs --src src --docs README.md docs/` prints documented and undocumented symbols and exits non-zero with `--strict`. It finds JavaScript and TypeScript exports and top-level Python definitions by name matching, so treat the output as a to-check list rather than a verdict.
references/examples.md
# Worked documentation jobs
Two jobs from source to finished text. Both show the source that was read first, because that is the part people skip and it is the part that makes the difference between documentation and fiction.
## Contents
- [Job 1: a small service with no README](#job-1-a-small-service-with-no-readme)
- [Job 2: an API reference where the gaps were the point](#job-2-an-api-reference-where-the-gaps-were-the-point)
- [Reading the coverage output](#reading-the-coverage-output)
- [Phrases that keep the writing honest](#phrases-that-keep-the-writing-honest)
---
## Job 1: a small service with no README
### What was read
`package.json` scripts and dependencies, `src/index.js` (startup and route table),
`src/config.js` (every environment variable and its default), `docker-compose.yml`
(the services it expects), and the two files under `src/routes/`.
```js
// src/config.js
export const config = {
port: Number(process.env.PORT ?? 8080),
databaseUrl: process.env.DATABASE_URL, // no default, read at startup
cacheTtlSeconds: Number(process.env.CACHE_TTL ?? 300),
logLevel: process.env.LOG_LEVEL ?? 'info',
maxUploadBytes: Number(process.env.MAX_UPLOAD ?? 5 * 1024 * 1024),
};
if (!config.databaseUrl) {
throw new Error('DATABASE_URL is required');
}
```
### What was run
```bash
$ npm ci # 1,204 packages, 38s
$ docker compose up -d db # postgres:15 on 5432
$ DATABASE_URL=postgres://localhost/thumbs npm run migrate
4 migrations applied
$ DATABASE_URL=postgres://localhost/thumbs npm start
listening on 8080
```
Running it produced two facts that reading did not: `npm run migrate` must happen
before the first start or the server exits with an unhelpful stack trace, and the
compose file names the database `thumbs` while the README draft had called it
`thumbnails`.
### What was written
> # thumbs
>
> An HTTP service that generates and caches image thumbnails. Applications post an
> image URL and a size; the service fetches, resizes, caches and serves the result.
> It is intended to run behind an internal network - it has no authentication of
> its own.
>
> ## Requirements
>
> - Node 20 or later (`engines` in package.json).
> - PostgreSQL 15 (the version in `docker-compose.yml`).
> - Roughly 200MB of disk for the cache directory, which is not bounded - see
> open questions.
>
> ## Setup
>
> ```bash
> npm ci
> docker compose up -d db
> export DATABASE_URL=postgres://localhost/thumbs
> npm run migrate # required before the first start
> npm start # listens on 8080
> ```
>
> All five commands were run from a clean checkout for this document.
>
> ## Usage
>
> ```bash
> curl -X POST localhost:8080/thumbnails \
> -H 'content-type: application/json' \
> -d '{"url": "https://example.com/photo.jpg", "width": 320}'
> ```
>
> ```json
> { "id": "9f2c...", "url": "/thumbnails/9f2c...", "cached": false }
> ```
>
> A repeat request for the same URL and width returns `"cached": true` and does not
> refetch the source image.
>
> ## Configuration
>
> | Variable | Default | Effect |
> | --- | --- | --- |
> | `DATABASE_URL` | none - required | Postgres connection string. The service exits at startup if it is unset. |
> | `PORT` | `8080` | HTTP port. |
> | `CACHE_TTL` | `300` | Seconds a generated thumbnail is served before being regenerated. |
> | `LOG_LEVEL` | `info` | One of `debug`, `info`, `warn`, `error`. |
> | `MAX_UPLOAD` | `5242880` | Largest source image accepted, in bytes. Larger sources return 413. |
>
> ## Troubleshooting
>
> - **Exits immediately with "DATABASE_URL is required"** - the variable is unset.
> It is read at startup, before any connection attempt.
> - **First request 500s with a missing-relation error** - `npm run migrate` has not
> been run against this database.
>
> ## Open questions
>
> - The cache directory is never pruned. `CACHE_TTL` controls how long a thumbnail
> is *served*, not how long the file is *kept*, so disk grows without bound. Is
> pruning handled outside the service, or is this a gap?
> - `src/routes/health.js` reports `ok` without checking the database connection.
> Deliberate, or should the check be added?
Note what is absent. No feature list beyond what the routes do, no authentication
section because there is none, and no claim about cache size, because the code does
not bound it. The disk growth went to open questions rather than being softened into
a sentence that implies it is fine.
---
## Job 2: an API reference where the gaps were the point
### The source
```python
# api/webhooks.py
@router.post("/webhooks/{integration_id}")
async def receive(integration_id: str, request: Request, x_signature: str = Header(None)):
"""Receive an event from a connected integration."""
body = await request.body()
if not verify_signature(integration_id, body, x_signature):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(body) # raises on malformed JSON -> 500
if event.get("type") not in HANDLED_TYPES:
return {"status": "ignored"}
await enqueue(integration_id, event)
return {"status": "queued", "event_id": event["id"]} # KeyError if id is absent
```
### The reference that was written
> ### `POST /webhooks/{integration_id}`
>
> Receives an event from a connected integration and queues it for processing.
>
> **Path parameters**
>
> | Name | Type | Meaning |
> | --- | --- | --- |
> | `integration_id` | string | The connection this event belongs to. Determines which signing secret is used. |
>
> **Headers**
>
> | Name | Required | Meaning |
> | --- | --- | --- |
> | `X-Signature` | yes | HMAC of the raw request body. A missing header is treated as a failed check and returns 401. |
>
> **Responses**
>
> | Status | Body | When |
> | --- | --- | --- |
> | 200 | `{"status": "queued", "event_id": "..."}` | The event was accepted and queued. |
> | 200 | `{"status": "ignored"}` | The event type is not one this service handles. The list is `HANDLED_TYPES` in `api/webhooks.py`. |
> | 401 | `{"detail": "bad signature"}` | The signature did not verify, or the header was absent. |
>
> **Example**
>
> ```bash
> curl -X POST https://api.example.com/webhooks/ghub_2831 \
> -H "X-Signature: sha256=..." \
> -H "content-type: application/json" \
> -d '{"id": "evt_9912", "type": "issue.opened", "payload": {}}'
> ```
>
> ```json
> {"status": "queued", "event_id": "evt_9912"}
> ```
>
> **Open questions**
>
> 1. A body that is not valid JSON raises inside the handler and surfaces as a 500.
> Senders will retry a 500, so a malformed event will be retried forever. Should
> this be a 400?
> 2. A valid JSON event with no `id` field raises `KeyError` after the event has
> already been queued, so the caller sees a 500 for work that was accepted. Is
> `id` guaranteed by every integration?
> 3. There is no documented size limit on the body. The code does not impose one;
> whether the proxy does could not be determined from this repository.
The three open questions took ten minutes to find and are worth more than the
reference above them. None of them could have been written by someone documenting
the endpoint's intent rather than its code. The docstring says only "receive an
event from a connected integration", and every one of these behaviours contradicts
what a reader would assume from it.
What was deliberately not written: a 400 response row. The code does not return
400, so documenting one would create an expectation the service does not meet. The
gap is described in the open questions instead, where it can be fixed rather than
papered over.
---
## Reading the coverage output
`scripts/doc-coverage.mjs` on the first project, after the README was written:
```
$ node scripts/doc-coverage.mjs --src src --docs README.md
source files: 14
doc files: 1
public names: 23
unmentioned: 9
Not mentioned in any documentation file:
buildCacheKey src/cache.js:12
pruneExpired src/cache.js:41
resizeToWidth src/image.js:8
...
```
Nine unmentioned names is not nine missing sections. `buildCacheKey` and
`resizeToWidth` are internal helpers that happen to be exported for testing, and
documenting them would present internals as public interface.
`pruneExpired` is the interesting one: a function that prunes the cache exists, and
nothing calls it. That is where the open question about unbounded disk growth in
job 1 came from. The tool did not find the gap. It pointed at the name, and reading
the name's call sites found the gap.
---
## Phrases that keep the writing honest
- "Not run - requires a database this environment does not have."
- "Taken from the code; the command was not executed."
- "The code and the existing documentation disagree here. The code does X; the docs say Y. Documented X."
- "This flag is parsed but never read, so it currently has no effect."
- "Behaviour above 10,000 rows could not be determined from the source."
- "Not documented: `internal/`, which appears to be private."
Each one gives the reader something to act on. The alternative in every case is a
confident sentence that someone will later discover was invented.
scripts/doc-coverage.mjs
#!/usr/bin/env node
/**
* doc-coverage - lists the public surface of a codebase that no documentation
* file mentions by name.
*
* It is a name-matching tool, not a parser: it collects exported JavaScript and
* TypeScript symbols plus top-level Python definitions, then checks whether each
* name appears as a whole word anywhere in the given documentation files. Use the
* output as a list of things to go and check, not as a verdict - a symbol can be
* named in the docs and still be documented badly.
*
* Usage:
* node doc-coverage.mjs --src src --docs README.md
* node doc-coverage.mjs --src src --src lib --docs README.md docs/ --strict
*
* Exit codes: 0 always, unless --strict is given and something is undocumented,
* in which case 1. Bad arguments or unreadable paths also exit 1.
*
* Node stdlib only.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { extname, join, relative, resolve, sep } from 'node:path';
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.py']);
const DOC_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.txt']);
const SKIP_DIRECTORIES = new Set([
'node_modules', '.git', 'dist', 'build', 'coverage',
'__pycache__', '.venv', 'venv', '.next', 'out',
]);
const PRIVATE_PREFIX = '_';
/** Patterns that name an exported symbol. Group 1 is always the name. */
const EXPORT_PATTERNS = [
/^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/,
/^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/,
/^\s*export\s+(?:type|interface|enum)\s+([A-Za-z_$][\w$]*)/,
/^\s*export\s+default\s+(?:async\s+)?(?:function|class)\s+([A-Za-z_$][\w$]*)/,
/^\s*def\s+([A-Za-z_][\w]*)\s*\(/,
/^\s*class\s+([A-Za-z_][\w]*)\s*[(:]/,
];
/** `export { a, b as c }` needs its own handling because it names several symbols. */
const EXPORT_LIST_RE = /^\s*export\s*\{([^}]*)\}/;
function parseArgs(argv) {
const options = { src: [], docs: [], strict: false, help: false };
let current = null;
for (const arg of argv) {
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--strict') {
options.strict = true;
} else if (arg === '--src' || arg === '--docs') {
current = arg === '--src' ? 'src' : 'docs';
} else if (arg.startsWith('--')) {
throw new Error(`unknown flag ${arg}`);
} else if (current) {
options[current].push(arg);
} else {
throw new Error(`unexpected argument "${arg}" - it must follow --src or --docs`);
}
}
return options;
}
/** Collect files under a path (file or directory) whose extension is in `wanted`. */
function collectFiles(target, wanted) {
const found = [];
let info;
try {
info = statSync(target);
} catch (error) {
throw new Error(`cannot read "${target}": ${error.message}`);
}
if (info.isFile()) {
if (wanted.has(extname(target).toLowerCase())) found.push(target);
return found;
}
let entries;
try {
entries = readdirSync(target, { withFileTypes: true });
} catch (error) {
throw new Error(`cannot list "${target}": ${error.message}`);
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRECTORIES.has(entry.name)) continue;
found.push(...collectFiles(join(target, entry.name), wanted));
} else if (wanted.has(extname(entry.name).toLowerCase())) {
found.push(join(target, entry.name));
}
}
return found;
}
function readFile(path) {
try {
return readFileSync(path, 'utf8');
} catch (error) {
throw new Error(`cannot read "${path}": ${error.message}`);
}
}
/** @returns {Array<{name: string, file: string, line: number}>} */
function findExportedSymbols(files, root) {
const symbols = [];
const seen = new Set();
for (const file of files) {
const lines = readFile(file).split('\n');
const isPython = extname(file).toLowerCase() === '.py';
lines.forEach((line, index) => {
// Python: only module-level definitions count as public surface.
if (isPython && /^\s/.test(line)) return;
const names = [];
for (const pattern of EXPORT_PATTERNS) {
const match = pattern.exec(line);
if (match) {
names.push(match[1]);
break;
}
}
const listMatch = EXPORT_LIST_RE.exec(line);
if (listMatch) {
for (const part of listMatch[1].split(',')) {
const alias = part.includes(' as ') ? part.split(' as ')[1] : part;
const name = alias.trim();
if (name && name !== 'default') names.push(name);
}
}
for (const name of names) {
if (name.startsWith(PRIVATE_PREFIX)) continue;
const key = `${name}@${file}`;
if (seen.has(key)) continue;
seen.add(key);
symbols.push({ name, file: relative(root, file).split(sep).join('/'), line: index + 1 });
}
});
}
return symbols;
}
function isMentioned(name, documentation) {
// Whole-word match so `parse` does not match `parseArgs`.
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^\\w$])${escaped}([^\\w$]|$)`).test(documentation);
}
function main(argv) {
let options;
try {
options = parseArgs(argv);
} catch (error) {
console.error(error.message);
return 1;
}
if (options.help || options.src.length === 0 || options.docs.length === 0) {
console.error('usage: node doc-coverage.mjs --src <dir|file> [more...] --docs <dir|file> [more...] [--strict]');
return options.help ? 0 : 1;
}
const root = process.cwd();
let codeFiles = [];
let docFiles = [];
try {
for (const target of options.src) codeFiles.push(...collectFiles(resolve(target), CODE_EXTENSIONS));
for (const target of options.docs) docFiles.push(...collectFiles(resolve(target), DOC_EXTENSIONS));
} catch (error) {
console.error(error.message);
return 1;
}
if (codeFiles.length === 0) {
console.error('no source files found - check --src');
return 1;
}
if (docFiles.length === 0) {
console.error('no documentation files found - check --docs');
return 1;
}
let symbols;
let documentation;
try {
symbols = findExportedSymbols(codeFiles, root);
documentation = docFiles.map(readFile).join('\n');
} catch (error) {
console.error(error.message);
return 1;
}
const undocumented = symbols.filter((symbol) => !isMentioned(symbol.name, documentation));
console.log(`source files: ${codeFiles.length}`);
console.log(`doc files: ${docFiles.length}`);
console.log(`public names: ${symbols.length}`);
console.log(`unmentioned: ${undocumented.length}`);
if (undocumented.length > 0) {
console.log('\nNot mentioned in any documentation file:');
for (const symbol of undocumented) {
console.log(` ${symbol.name.padEnd(32)} ${symbol.file}:${symbol.line}`);
}
console.log('\nEach line is a name to check by hand - some of these are internal on purpose.');
}
return options.strict && undocumented.length > 0 ? 1 : 0;
}
process.exit(main(process.argv.slice(2)));
Install it
Claude Code (this project)
Unzip the download into .claude/skills/. The folder is already named right.
Claude Code (every project)
Unzip into ~/.claude/skills/ to use it everywhere.
claude.ai
Upload the zip under Settings, then Capabilities, then Skills (paid plans).
How to call it
Just describe the task. The description line inside the skill does the matching. Or call it directly:
/readme-and-docs-writerwrite a README for this projectthe docs are out of date, update them from the codedocument this APIwhat should the setup instructions actually say?which parts of this codebase are undocumented?
Works beyond Claude
Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.
Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.