A wiki that describes your code goes out of date as soon as someone refactors a module. Nobody updates it, and readers stop trusting it. You can ask an LLM to write the documentation from the source instead, but a script that sends every file to the model on every run is slow and costs money, and most files did not change since the last run.
This tutorial builds a CocoIndex v1 pipeline that writes one Markdown wiki page for each Python project in a folder. An LLM reads each file and returns structured data: public classes, public functions, a summary, and Mermaid call graphs. The pipeline combines the files into a project summary and writes the page. When you run it again, the LLM re-reads only the files that changed, plus one summary call for each multi-file project they belong to. This is incremental processing.
The full example is in the CocoIndex repo, with a walkthrough in the docs.
What you build
- A CocoIndex app,
MultiCodebaseSummarization, inmain.py. It treats each subdirectory of a root folder as one project. By default the root is../, so in the CocoIndex repo it documents the folders inexamples/(about 40 when this was updated). - Three Pydantic models in
models.py(CodebaseInfo,ClassInfo,FunctionInfo) that define what the LLM returns for a file and for a project. - One LLM call per Python file through Instructor and LiteLLM, plus one more call per multi-file project to combine the file summaries.
- One Markdown page per project in
output/, with an Overview, a Components list, a Mermaid diagram of the CocoIndex pipeline when the project has one, and per-file summaries.
You need Python 3.11+, cocoindex>=1.0.7 (1.1 at the time of writing), and an API key for an LLM. The default model is gemini/gemini-2.5-flash, so the default key is GEMINI_API_KEY. Run it has the commands.
A few terms used below:
- Structured output: the LLM returns JSON that matches a schema (here, a Pydantic model), and the code validates it, so you get typed objects instead of free text.
- Memoization: CocoIndex caches the result of a function and reuses it while the inputs and the function’s code stay the same.
- Processing component: a unit of work that CocoIndex runs, tracks and cleans up on its own. Here, each project is one component.
Why not write a script?
A script that loops through files, calls an LLM and writes Markdown works for a one-time run. Keeping it correct over time raises several problems:
- File changes: after you edit one file, re-running the whole pipeline is slow and expensive.
- Tracking state: to process only what changed, you have to track timestamps, checksums or diffs yourself.
- LLM costs: re-analyzing unchanged files wastes API calls, and the cost grows with the size of the codebase.
- Logic changes: when you change the extraction prompt, every result from the old prompt is stale and must be recomputed.
These are caching, invalidation and orchestration concerns, separate from the extraction logic you want to write. Codebases also change often, as people and coding agents add and modify files. A scheduled full rebuild is out of date until it runs again, and most of its work is on files that did not change.
CocoIndex separates the transformation logic from the change tracking. You write the functions that extract, combine and format, as plain async Python. The model behind this is one relationship:
target_state = transformation(source_state)
You declare the transformation, and CocoIndex decides which inputs need reprocessing. When a source file changes, or the code of a function changes (an edited prompt, for example), only the affected outputs are recomputed.
How the pipeline fits together
app_main finds the projects and mounts one process_project component for each. Inside a component, every Python file goes to the LLM once, the results are combined into one project summary, and the summary becomes a Markdown file. The sections below follow the data: the models first, then each function, then the wiring.
Step 1: Define the data models
The LLM fills in Pydantic models. The field descriptions are part of the prompt: Instructor sends the schema, with its descriptions, to the model. CodebaseInfo serves twice, once per file and once per project:
"""Pydantic models for multi-codebase summarization."""
from pydantic import BaseModel, Field
class FunctionInfo(BaseModel):
"""Information about a public function."""
name: str = Field(description="Function name")
signature: str = Field(
description="Function signature, e.g. 'async def foo(x: int) -> str'"
)
is_coco_function: bool = Field(description="Whether decorated with @coco.fn")
summary: str = Field(description="Brief summary of what the function does")
class ClassInfo(BaseModel):
"""Information about a public class."""
name: str = Field(description="Class name")
summary: str = Field(description="Brief summary of what the class represents/does")
MERMAID_GRAPH_DESCRIPTION = """
Mermaid graph showing CocoIndex function call relationships. Requirements:
- Use 'graph TD' (top-down) layout
- Include ONLY functions defined in this codebase (not external libraries)
- Use **bold** text for @coco.fn decorated functions
- Use thick arrows (==>) for coco.mount/coco.use_mount calls
- Use normal arrows (-->) for regular function calls
- Include a comment with the app name if this is for a coco.App
Example:
```mermaid
graph TD
%% App: MyApp
app_main[<b>app_main</b>] ==> process_file[<b>process_file</b>]
process_file --> helper_func[helper_func]
```
"""
class CodebaseInfo(BaseModel):
"""Extracted information from Python code (file or project level)."""
name: str = Field(
description="File path (for files) or project name (for projects)"
)
summary: str = Field(description="Brief summary of purpose and functionality")
public_classes: list[ClassInfo] = Field(
default_factory=list,
description="Public classes (not starting with _)",
)
public_functions: list[FunctionInfo] = Field(
default_factory=list,
description="Public functions (not starting with _)",
)
mermaid_graphs: list[str] = Field(
default_factory=list,
description=MERMAID_GRAPH_DESCRIPTION,
)
MERMAID_GRAPH_DESCRIPTION is the longest instruction in the example. It asks for a top-down graph of the codebase’s own functions, bold labels for @coco.fn functions, and thick ==> arrows for coco.mount calls, so every page draws its diagram the same way.
Step 2: Extract one file with an LLM
extract_file_info reads one file, puts its path and content into a prompt, and asks for a CodebaseInfo. instructor.from_litellm wraps LiteLLM’s async acompletion, so response_model=CodebaseInfo returns a validated object for any model LiteLLM supports. Mode.JSON asks the model for JSON in the schema:
LLM_MODEL = os.environ.get("LLM_MODEL", "gemini/gemini-2.5-flash")
_instructor_client = cast(
instructor.AsyncInstructor,
instructor.from_litellm(acompletion, mode=instructor.Mode.JSON),
)
@coco.fn(memo=True)
async def extract_file_info(file: FileLike) -> CodebaseInfo:
"""Extract structured information from a single Python file using LLM."""
content = await file.read_text()
file_path = str(file.file_path.path)
prompt = f"""Analyze the following Python file and extract structured information.
File path: {file_path}
```python
{content}
```
Instructions:
1. Identify all PUBLIC classes (not starting with _) and summarize their purpose
2. Identify all PUBLIC functions (not starting with _) and summarize their purpose
3. If this file contains CocoIndex apps (coco.App), create Mermaid graphs showing the
function call relationships (see the mermaid_graphs field description for format)
4. Provide a brief summary of the file's purpose
"""
result = await _instructor_client.chat.completions.create(
model=LLM_MODEL,
response_model=CodebaseInfo,
messages=[{"role": "user", "content": prompt}],
)
return CodebaseInfo.model_validate(result.model_dump())
memo=True caches the result for each file. For a file argument, CocoIndex checks the modified time first, and reads the content for a fingerprint only when the time changed. A file you save without changes still hits the cache. The prompt is in the function body, so editing it changes the function’s code, and every cached result for it misses once.
LLM_MODEL is a module-level value, read from the environment when main.py is imported, and the example does not list it in deps. If you change only LLM_MODEL, files that did not change keep their cached results from the old model.
What you see. Each call returns one CodebaseInfo whose name is the file path. Its lists hold the public classes and functions the model found. The prompt asks for mermaid_graphs only when the file contains a coco.App.
Quick check: you reword instruction 4 in the prompt. Which files go to the LLM on the next run?
All of them. The prompt is part of the source code of extract_file_info, and CocoIndex includes a function’s code in its memo key, so every cached result misses once. The change also invalidates the memo of process_project, which calls it, so every project runs again. On the run after that, unchanged files hit the cache again.
Step 3: Combine the files into one project summary
aggregate_project_info turns the per-file results into one CodebaseInfo for the project. It calls the LLM only when the project has more than one file:
@coco.fn
async def aggregate_project_info(
project_name: str,
file_infos: list[CodebaseInfo],
) -> CodebaseInfo:
"""Aggregate multiple file extractions into a project-level summary."""
if not file_infos:
return CodebaseInfo(
name=project_name, summary="Empty project with no Python files."
)
# Single file - just update the name to be the project name
if len(file_infos) == 1:
info = file_infos[0]
return CodebaseInfo(
name=project_name,
summary=info.summary,
public_classes=info.public_classes,
public_functions=info.public_functions,
mermaid_graphs=info.mermaid_graphs,
)
# Multiple files - use LLM to create aggregated summary
# Format file summaries for the prompt
files_text = "\n\n".join(
f"### {info.name}\n"
f"Summary: {info.summary}\n"
f"Classes: {', '.join(c.name for c in info.public_classes) or 'None'}\n"
f"Functions: {', '.join(f.name for f in info.public_functions) or 'None'}"
for info in file_infos
)
# Collect all mermaid graphs from files
all_graphs = [g for info in file_infos for g in info.mermaid_graphs]
prompt = f"""Aggregate the following Python files into a project-level summary.
Project name: {project_name}
Files:
{files_text}
Create a unified CodebaseInfo that:
1. Summarizes the overall project purpose (not individual files)
2. Lists the most important public classes across all files
3. Lists the most important public functions across all files
4. For mermaid_graphs: create a single unified graph showing how the CocoIndex
components connect across the project (if applicable)
"""
result = await _instructor_client.chat.completions.create(
model=LLM_MODEL,
response_model=CodebaseInfo,
messages=[{"role": "user", "content": prompt}],
)
result = CodebaseInfo.model_validate(result.model_dump())
# Keep original file-level graphs if LLM didn't generate a unified one
if not result.mermaid_graphs and all_graphs:
result.mermaid_graphs = all_graphs
return result
The aggregation prompt sees each file’s name, summary and the names of its classes and functions, not the source code. If the model returns no unified graph, the file-level graphs are kept.
aggregate_project_info is not memoized. It runs each time its component runs, so a change to any file in a multi-file project costs one extraction call for that file plus one aggregation call.
Step 4: Render the Markdown page
generate_markdown is plain string formatting, with no LLM call:
@coco.fn
def generate_markdown(
project_name: str, info: CodebaseInfo, file_infos: list[CodebaseInfo]
) -> str:
"""Generate markdown documentation from project info."""
lines = [
f"# {project_name}",
"",
"## Overview",
"",
info.summary,
"",
]
# Components
if info.public_classes or info.public_functions:
lines.extend(["## Components", ""])
if info.public_classes:
lines.append("**Classes:**")
for cls in info.public_classes:
lines.append(f"- `{cls.name}`: {cls.summary}")
lines.append("")
if info.public_functions:
lines.append("**Functions:**")
for fn in info.public_functions:
marker = " ★" if fn.is_coco_function else ""
lines.append(f"- `{fn.signature}`{marker}: {fn.summary}")
lines.append("")
# Mermaid graphs
if info.mermaid_graphs:
lines.extend(["## CocoIndex Pipeline", ""])
for graph in info.mermaid_graphs:
# Ensure proper code fence wrapping (LLM may or may not include them)
graph_content = graph.strip()
if graph_content.startswith("```"):
# Already has fences, use as-is
lines.append(graph_content)
else:
# Add mermaid code fences
lines.append("```mermaid")
lines.append(graph_content)
lines.append("```")
lines.append("")
# File details (if multiple files)
if len(file_infos) > 1:
lines.extend(["## File Details", ""])
for fi in file_infos:
lines.extend([f"### {fi.name}", "", fi.summary, ""])
# Legend
lines.extend(["---", "", "*★ = CocoIndex function*"])
return "\n".join(lines)
The page has 4 sections; only Overview is always present:
- Overview: the project summary.
- Components: classes, and functions by signature; ★ marks a function decorated with
@coco.fn. Omitted when the model found no public classes or functions. - CocoIndex Pipeline: each Mermaid graph in a
mermaidcode fence. The function adds the fence only when the model did not already include one. Omitted when there is no graph. - File Details: one summary per file, for projects with 2 or more files.
Check the generated diagrams against the code. A response that validates against the schema can still describe a call relationship incorrectly.
Step 5: Mount one component per project
process_project runs the 3 steps for one project and declares the output file:
@coco.fn(memo=True)
async def process_project(
project_name: str,
files: Collection[localfs.File],
output_dir: pathlib.Path,
) -> None:
"""Process a single project: extract info from all files, aggregate, and output markdown."""
# Extract info from each file.
file_infos = await coco.map(extract_file_info, files)
# Aggregate into project-level summary
project_info = await aggregate_project_info(project_name, file_infos)
# Generate and output markdown
markdown = generate_markdown(project_name, project_info, file_infos)
localfs.declare_file(
output_dir / f"{project_name}.md", markdown, create_parent_dirs=True
)
coco.map runs extract_file_info on every file concurrently and returns the results in input order. It creates no new components: the calls run as async tasks inside the project’s component. localfs.declare_file declares a target state, a file that should exist with this content. CocoIndex writes it, rewrites it when the content changes, and deletes it when no component declares it any more.
app_main lists the projects and mounts the components:
@coco.fn
async def app_main(
root_dir: pathlib.Path,
output_dir: pathlib.Path,
) -> None:
"""
Main application function.
Scans subdirectories of root_dir, treating each as a Python project,
and generates markdown documentation for each.
"""
# List subdirectories (each is a project)
for entry in root_dir.resolve().iterdir():
# Skip non-directories and hidden directories
if not entry.is_dir() or entry.name.startswith("."):
continue
project_name = entry.name
# Walk Python files in this project, excluding .venv directories
files = [
f
async for f in localfs.walk_dir(
entry,
recursive=True,
path_matcher=PatternFilePathMatcher(
included_patterns=["**/*.py"],
excluded_patterns=["**/.*", "**/__pycache__"],
),
)
]
if files:
# Mount a component to process this project
await coco.mount(
coco.component_subpath("project", project_name),
process_project,
project_name,
files,
output_dir,
)
app = coco.App(
coco.AppConfig(name="MultiCodebaseSummarization"),
app_main,
root_dir=pathlib.Path("../"),
output_dir=pathlib.Path("./output"),
)
localfs.walk_dir is an async iterator, so the list is built with async for. The patterns match Python files at any depth and skip hidden paths (such as .venv) and __pycache__. A folder with no Python files gets no component.
You choose the granularity. A component could be one file, or a smaller unit such as a page or a section. This example uses one component per project directory because it writes one page per project. coco.mount takes a stable path, ("project", project_name), and each processing component owns the target states it declares, here its Markdown file. Components run independently: when one project finishes, its page is written without waiting for the other projects.
process_project is memoized too. When none of a project’s files changed and the code of the functions it calls is unchanged, CocoIndex skips the component’s body and keeps the page it declared last time, so that project makes no LLM call.
Quick check: you delete a project folder and run the pipeline again. What happens to its page?
It is deleted. app_main no longer finds the folder, so it does not mount a component at ("project", <name>). CocoIndex cleans up the target states of a component that is no longer mounted, and output/<name>.md was one of them. No LLM call runs for that project.
Run it
From examples/multi_codebase_summarization in the CocoIndex repo, copy the environment template and set your key, then install the example:
cp .env.example .env # set GEMINI_API_KEY (or LLM_MODEL=<provider/model> with its matching key)
pip install -e .
pip install -e . installs cocoindex>=1.0.7, instructor, litellm, pydantic and python-dotenv from pyproject.toml. The example’s .env sets COCOINDEX_DB=./cocoindex.db, the local database where CocoIndex keeps its tracking state and memo cache. The cocoindex CLI loads the nearest .env, searching up from the current directory.
Build the wiki:
cocoindex update main.py
root_dir is ../, so this documents the folders in examples/, the example’s own folder included. To document your own code, point root_dir in main.py at a folder of project subdirectories:
my_projects/
├── my_project_1/
│ ├── main.py
│ └── utils.py
├── my_project_2/
│ └── app.py
└── ...
Read the output:
ls output/
cat output/code_embedding.md
What you see. output/ holds one <folder name>.md per project folder that contains Python files, such as code_embedding.md. Each page starts with # <folder name> and ## Overview, and ends with *★ = CocoIndex function*. The summaries and diagrams are written by the model, so their wording differs between models and between runs that miss the cache.
What a re-run does
Every cocoindex update main.py lists the projects again and mounts a component for each. LLM calls happen only where a memo cache misses:
- An edited file misses its
extract_file_infocache. The other files in the project hit theirs. For a multi-file project,aggregate_project_infomakes one more call. - A new project folder gets a new component. Only its files go to the LLM, plus one aggregation call if it has 2 or more files.
- A deleted project folder loses its component, and CocoIndex deletes its page.
- An edited prompt changes the code of
extract_file_info. That invalidates its cached results and the memo ofprocess_project, which calls it, so every file misses once.
Quick check: a project has 5 Python files and you edit one of them. How many LLM calls does the next run make for that project?
2: 1 extract_file_info call for the edited file, and 1 aggregate_project_info call, because the project has more than one file. The other 4 files hit the cache. For a single-file project, the answer is 1, because aggregation reuses the file’s info without a call.
Beyond documentation: keeping derived knowledge current
The same pattern applies wherever derived knowledge has to follow a changing source, including the context that coding agents read. Agents that work over long sessions act on the codebase as it is now. A summary that was accurate last week can lead an agent to act on assumptions that no longer hold.
Processing the change, instead of rebuilding the whole corpus, keeps this practical:
- Cost: unchanged files do not cause repeated LLM calls.
- Latency: a run after a small change spends LLM time only on the changed files.
- Scale: after the first full run, a large codebase stays current without full reprocessing.
It is the same incremental pattern behind indexing a codebase for RAG, text embeddings and knowledge graphs: declare the transformation once, and only changed inputs are reprocessed.
What to try next
- Document your own repos. Point
root_dirat a folder of project subdirectories and runcocoindex update main.py. - Switch the model. Set
LLM_MODELin.envto another LiteLLM provider model and add its key. Check that it returns JSON that validates againstCodebaseInfo. Cached results from the old model stay in use for unchanged files, becauseLLM_MODELis not indeps; seedepsto make it part of the memo key. - Change what the page says. Add a field to
CodebaseInfoand a section togenerate_markdown. A new field changes the schema the model sees, so check the output on one project first. - Refresh from CI. Run
cocoindex update main.pyfrom a commit hook or a CI job. The example scans the folders once per run; for a process that keeps running, read about live mode. - Give the pages to an agent. Each page is a short Markdown summary of a project, with its public API and call graph, that you can put into a coding agent’s context.
Further reading:
- The example on GitHub:
main.py,models.pyand the README - The example walkthrough in the docs
- Functions and memoization:
memo=True,depsand change detection - Memoization keys and states: how file arguments are fingerprinted
- Processing components: mounting, ownership and cleanup
Thanks to the community
@prrao87
If you have ideas or questions, or want to contribute, join us on Discord. The source is on GitHub.
Frequently asked questions
How do I keep my code documentation in sync with the code without re-running the LLM on every file?
Run the CocoIndex pipeline again after code changes with cocoindex update main.py. It lists the projects again and reuses memoized file extractions whose inputs and code are unchanged. An edited file triggers a new extraction and, for a multi-file project, a new project summary, while unchanged projects reuse their cached results. The example writes Markdown files; updates happen when you run the pipeline. See What a re-run does and the incremental processing deep dive.
How do I only re-analyze changed files when generating docs with an LLM (skip unchanged ones)?
Enable memoization on the per-file extraction with @coco.fn(memo=True), as the example does on extract_file_info. For a file argument, CocoIndex checks the modified time and then a content fingerprint, and reuses the extraction instead of calling the LLM when the file is unchanged. See Step 2: Extract one file with an LLM and Memoization keys & states.
How do I cut LLM costs when regenerating codebase docs on every commit?
Cache the expensive per-file extractions with @coco.fn(memo=True). Later runs reuse cached results for unchanged files, and a project with no changed files makes no LLM call. A changed file still costs one extraction call, plus one aggregation call when its project has more than one file. The article does not measure a percentage reduction; savings depend on the changed files, project sizes, and model. See What a re-run does and the memoization documentation.
How do I give my AI coding agent always-fresh, structured context about my own codebase?
Run the pipeline after code changes and give the generated Markdown pages to your agent. Each page contains a project summary, public classes and functions, and any Mermaid diagrams produced by the model. A commit hook or CI job can trigger updates; the example does not write JSON artifacts or install a watcher. See why current context matters and What you build.
For a self-hosted service that keeps every repository on your code hosts indexed and answers agents’ questions with citations to file and line, see CocoIndex Code Plus.
How do I build a self-updating wiki for a monorepo or many repos?
Point the pipeline at a parent folder and treat each subdirectory as a project. app_main walks each project's Python files, and await coco.mount(coco.component_subpath("project", project_name), ...) gives each project its own processing component. On the next update, a new project gets processed, unchanged projects reuse cached results, and a deleted project's page is removed. See Step 5: Mount one component per project and the full example.
How do I build a self-hosted DeepWiki-style codebase wiki?
The pipeline and generated wiki files run in your own environment, and you can edit the prompt, Pydantic models, aggregation, and output format. The default Gemini configuration sends source text to a hosted model. If source must stay in your environment, configure a compatible locally hosted model and validate its structured output. Start from the example and read Why not write a script?
How do I extract structured data (classes, functions, relationships) from source code with Pydantic and an LLM?
Define Pydantic models for exactly what you want (for example FunctionInfo, ClassInfo, and a top-level CodebaseInfo), then call the LLM through Instructor with response_model=CodebaseInfo. You get validated, typed objects back instead of free-form text. See Step 1: Define the data models and Step 2, with full code in models.py and main.py.
How do I generate a Mermaid architecture diagram from code automatically?
Ask the LLM for Mermaid graphs in a Pydantic field such as mermaid_graphs: list[str], with the drawing rules in the field description, then render them in Markdown with mermaid code fences. The example regenerates the affected project's page on an update after code changes. Check the generated diagrams against the code, since a structured response can still describe relationships incorrectly. See Step 4: Render the Markdown page and the example source.
How do I aggregate per-file LLM summaries into a single project-level summary?
Run a second LLM step that takes the list of per-file extractions and synthesizes one project summary, skipping the extra call for single-file projects. The example runs the per-file extractions concurrently with coco.map(), then aggregates the results. See Step 3: Combine the files into one project summary.
How do I scan subdirectories where each folder is a separate project and process them as a pipeline?
In app_main, iterate root_dir.resolve().iterdir(), collect each project's Python files with [f async for f in localfs.walk_dir(...)] and a PatternFilePathMatcher (**/*.py), then await coco.mount() a processing component per project. Each project runs independently, and its page is written as soon as it finishes. See Step 5 and the main.py source.
How do I keep the codebase wiki updating automatically as code changes (continuous live mode)?
The example scans directories once per update. Run cocoindex update main.py from a commit hook or CI job to refresh the wiki after changes. For a continuously running version, add a live-aware source or a periodic refresh component and enable live mode. The CLI live flag alone does not make root_dir.iterdir() watch for new projects. See What to try next.
Can I use a different LLM (OpenAI GPT, Claude, or a local model) instead of Gemini for code summarization?
Yes. Set LLM_MODEL to a model supported by your LiteLLM and Instructor configuration, and provide that provider's credentials or local endpoint. The example defaults to gemini/gemini-2.5-flash. Check that the replacement validates against the Pydantic schema. The example does not list LLM_MODEL in deps, so unchanged files keep their cached results from the old model. See Run it and the Instructor/LiteLLM integration documentation.
Build fresh context for your agents.
CocoIndex keeps derived data in sync with its sources. Only what changed re-runs.


