This file provides guidance to coding agents working with code in this repository.
Claude Code compatibility is kept through CLAUDE.md, which imports this file.
This project uses uv for Python project management.
uv run maturin develop # Build Rust code and install Python package (required after Rust changes)cargo test # Run Rust tests
uv run mypy # Type check Python code
uv run pytest python/ # Run Python tests (use after both Rust and Python changes)uv run ruff format . # Format Python code
uv run ruff format --check . # Check formatting without making changes (same as CI)
uv run ruff check . # Lint Python code
cargo fmt # Format Rust codeCI enforces the full pre-commit hook suite from .pre-commit-config.yaml, which
covers more than the commands above — notably regenerating the CLI docs
(docs/src/content/docs/cli.mdx) whenever cli.py changes. Run it locally
before pushing:
uv tool install prek # one-time; some hooks also need `protoc` (brew install protobuf)
prek run --all-files| Change Type | Commands to Run |
|---|---|
| Rust code only | uv run maturin develop && cargo test |
| Python code only | uv run mypy && uv run pytest python/ |
| Both Rust and Python | Run all commands from both categories above |
| Python formatting | uv run ruff format . |
| Before pushing | prek run --all-files |
cocoindex/
├── rust/ # Rust crates (workspace)
│ ├── core/ # Core engine crate
│ │ └── src/
│ │ ├── engine/ # Core engine
│ │ ├── state/ # States of the core engine
│ │ └── inspect/ # Database inspection utilities
│ ├── py/ # Python bindings (PyO3)
│ ├── py_utils/ # Python-Rust utility helpers (error, convert, future)
│ ├── utils/ # General utilities: error, batching, fingerprint, etc.
│ └── ops_text/ # Text processing operations (splitter, language detection)
│
├── python/
│ ├── cocoindex/ # Python package
│ │ ├── __init__.py # Package entry point
│ │ ├── cli.py # CLI commands
│ │ ├── _internal/ # Internal implementation for the core engine
│ │ │ ├── api.py # Public API: mount, use_mount, mount_each, map, mount_target, App, fn, start/stop
│ │ │ ├── app.py # App base implementation
│ │ │ ├── context_keys.py # ContextKey and ContextProvider
│ │ │ ├── environment.py # Environment and lifespan handling
│ │ │ ├── function.py # @coco.fn decorator implementation
│ │ │ ├── component_ctx.py # ComponentContext and component_subpath
│ │ │ ├── target_state.py # Target state implementation
│ │ │ └── core.pyi # Type stubs for the Rust extension module (update when PyO3 APIs change)
│ │ ├── connectors/ # External system connectors (localfs, postgres, qdrant, lancedb, google_drive)
│ │ ├── connectorkits/ # Connector building utilities
│ │ ├── resources/ # Abstractions: file.py (FileLike), chunk.py (Chunk), schema.py
│ │ └── ops/ # Operations: text.py (RecursiveSplitter), sentence_transformers.py
│ └── tests/ # Python tests
│
├── examples/ # Example applications
├── docs/ # Documentation
└── dev/ # Development utilities
CocoIndex uses a declarative programming model — you specify what your output should look like (target states), not how to incrementally update it. The engine handles change detection and applies minimal updates automatically.
Think of it like:
- React: declare UI as function of state → React re-renders what changed
- Spreadsheets: declare formulas → cells recompute when inputs change
- CocoIndex: declare target states as function of source → engine syncs what changed
App — The top-level runnable unit. Bundles a main function with its arguments. When you call app.update(), the main function runs as the root processing component.
Processing Component — The unit of execution that owns a set of target states. Created by mount() or use_mount() at a specific component path. When a component finishes, its target states sync atomically to external systems.
Component Path — Stable identifier for a processing component across runs. Created via coco.component_subpath("process", filename). CocoIndex uses component paths to:
- Match components to their previous runs for change detection
- Determine ownership of target states (if a path disappears, its target states are cleaned up)
Target State — What you want to exist in an external system (a file, a database row, a table). You declare target states; CocoIndex keeps them in sync — creating, updating, or removing as needed.
Target — The API object used to declare target states (e.g., DirTarget, TableTarget). Targets can be nested: a container target state (directory/table) provides a Target for declaring child target states (files/rows).
Function — A Python function decorated with @coco.fn. Use memo=True to enable memoization (skip execution when inputs and code are unchanged).
Context — React-style provider mechanism for sharing resources. Define keys with ContextKey[T], provide values in lifespan via builder.provide(), use in functions via coco.use_context(key).
# Mounting processing components (subpath auto-derived from fn.__name__)
await coco.mount(fn, *args, **kw) # child runs independently
result = await coco.use_mount(fn, *args, **kw) # returns value directly
# Explicit subpath (for multi-part paths or multiple mounts of same function)
await coco.mount(coco.component_subpath("process", filename), fn, *args, **kw)
result = await coco.use_mount(coco.component_subpath("name"), fn, *args, **kw)
# Component subpath composition
subpath = coco.component_subpath("process", filename) # multiple parts
subpath = coco.component_subpath("a") / "b" / "c" # chaining with /
# Using component_subpath as context manager (applies to all nested mount calls)
with coco.component_subpath("process"):
for f in files:
await coco.mount(coco.component_subpath(str(f.relative_path)), process_file, f, target)
# Declaring target states (typically via Target methods)
dir_target.declare_file(filename=name, content=data)
table_target.declare_row(row=MyRow(...))
# Using context values
db = coco.use_context(PG_DB) # retrieve value provided in lifespan
# Explicit context management (for ThreadPoolExecutor)
ctx = coco.get_component_context()
with ctx.attach():
# coco APIs work correctly in this thread
coco.mount(...)Mount handles:
mount()→ComponentMountHandle: callawait handle.ready()to wait until target states are synceduse_mount()→ returns the result value directly (awaitable)
When a processing component finishes, CocoIndex compares its declared target states with those from the previous run at the same component path:
- New target states → create (insert row, create file)
- Changed target states → update
- Missing target states → delete
Changes are applied atomically per component. If a source item is deleted (path no longer mounted), all its target states are cleaned up automatically.
import pathlib
import cocoindex as coco
from cocoindex.connectors import localfs
from cocoindex.resources.file import FileLike, PatternFilePathMatcher
@coco.fn(memo=True)
async def process_file(file: FileLike, target: localfs.DirTarget) -> None:
html = _markdown_it.render(await file.read_text())
outname = "__".join(file.file_path.path.parts) + ".html"
target.declare_file(filename=outname, content=html)
@coco.fn
async def app_main(sourcedir: pathlib.Path, outdir: pathlib.Path) -> None:
target = await coco.use_mount(localfs.declare_dir_target, outdir)
files = localfs.walk_dir(
sourcedir,
recursive=True,
path_matcher=PatternFilePathMatcher(included_patterns=["**/*.md"]),
)
await coco.mount_each(process_file, files.items(), target)
app = coco.App(
coco.AppConfig(name="FilesTransform"),
app_main,
sourcedir=pathlib.Path("./docs"),
outdir=pathlib.Path("./out"),
)
app.update_blocking(report_to_stdout=True)We distinguish between internal modules (under packages with _ prefix, e.g. _internal.* or connectors.*._source) and external modules (which users can directly import).
External modules (user-facing, e.g. cocoindex/ops/sentence_transformers.py):
- Be strict about not leaking implementation details
- Use
__all__to explicitly list public exports - Prefix ALL non-public symbols with
_, including:- Standard library imports:
import threading as _threading,import typing as _typing - Third-party imports:
import numpy as _np,from numpy.typing import NDArray as _NDArray - Internal package imports:
from cocoindex.resources import schema as _schema
- Standard library imports:
- Exception:
TYPE_CHECKINGimports for type hints don't need prefixing
Internal modules (e.g. cocoindex/_internal/component_ctx.py, **/_target.py):
- Less strict since users shouldn't import these directly
- Standard library and internal imports don't need underscore prefix
- Only prefix symbols that are truly private to the module itself (e.g.
_context_varfor a module-private ContextVar)
When adding a new public API (function, class, kwarg, configuration option), prefer the smallest surface that solves the concrete need in front of you. Do not pre-expose tunables, hooks, or alternatives "in case someone needs them." Adding a kwarg later is straightforward and backwards-compatible; removing or renaming one is disruptive. If a knob is currently a hardcoded constant inside the implementation, leave it there until a real use case demands it — at which point promoting it to a kwarg is mechanical. This applies equally to optional parameters, callback hooks, parser/transformer plug-points, and configuration keys.
- Top-level imports. Defer to in-function only for a real circular dependency or a heavy import that isn't always needed.
- Specific types over
Any. Use concrete types — including concrete types from third-party libraries.Anyonly when the type is truly generic and no downstream code needs to downcast it. - Validate and exchange early. When a value enters as a weaker form (
str,Any, raw identifier), convert to the strong type at the earliest point. Don't propagate the weak form. Concrete example in this codebase: connector handlers receive aContextKeystring as part of a target-state key. The parent handler resolves the key once (when constructing the child handler) and passes the typed connection directly — the child stores_pool: asyncpg.Pool, not_db_key: str. NamedTuple/small dataclass for multi-value returns. Access fields by name (result.can_reuse) at call sites.- Exceptions for exceptional situations only. Reserve exceptions (Python) and errors (Rust) for truly unexpected failures — not for end-of-iteration, "not found", or other expected state transitions. Use explicit return values (sentinel, enum variant,
None) for those. - Single source of truth, delete dead code, honest names. When the same value/logic appears in multiple places, consolidate. When changes make code unreachable, delete it (and its tests, and any dead config knobs). Function and field names should describe what the implementation does today.
We prefer end-to-end tests on user-facing APIs, over unit tests on smaller internal functions. With this said, there're cases where unit tests are necessary, e.g. for internal logic with various situations and edge cases, in which case it's usually easier to cover various scenarios with unit tests.
Use common.create_test_env(__file__) to create a CocoIndex Environment for tests. It derives a unique db_path from the test file path and picks up the current event loop automatically.
-
Sync tests (module-level creation): Call at module level — the Environment creates a background loop for async callbacks.
coco_env = common.create_test_env(__file__)
-
Async tests with async resources (e.g., asyncpg pools): Call inside an async fixture so the Environment binds to the test's running event loop (same loop the pool is on). Use the
suffixparameter when each test needs its own Environment:@pytest_asyncio.fixture async def pg_env(request: pytest.FixtureRequest) -> Any: pool = await asyncpg.create_pool(dsn) coco_env = common.create_test_env(__file__, suffix=request.node.name) coco_env.context_provider.provide(DB_KEY, pool) yield pool, coco_env await pool.close()
The
suffixensures each test gets a uniquedb_path, avoiding "environment already open" errors.
Use testcontainers[postgres] (in the build-test dependency group) to spin up real database instances automatically — no manual setup or environment variables needed. Use a module-scoped sync fixture for the container and a function-scoped async fixture for per-test resources:
@pytest.fixture(scope="module")
def pg_dsn() -> Any:
with PostgresContainer("postgres:16-alpine") as pg:
dsn = pg.get_connection_url().replace("postgresql+psycopg2://", "postgresql://")
yield dsn
@pytest_asyncio.fixture
async def pool(pg_dsn: str) -> Any:
p = await asyncpg.create_pool(pg_dsn)
yield p
await p.close()- All LMDB writes must go through
Storage::run_txn(uses the single-writer batcher). - Do not open a heed write txn directly or wrap the env in a separate mutex/semaphore — bypassing the batcher loses fsync coalescing and regresses concurrent-submit throughput by 10-100×.
- LMDB has no savepoints. If a sub-operation needs to "abort," handle it at the body level (e.g. return a sentinel result without writing); never attempt per-body rollback inside the batcher.
The Rust core (rust/core, rust/utils) uses async-first design with Tokio. The rust/py crate bridges Rust async to Python, offering both sync and async APIs:
- Rust core exposes async functions
rust/pyprovides sync wrappers that useblock_on()to call async Rust from sync Python- Python's
cocoindexAPI is async-first: mount APIs (mount,use_mount,mount_each,map) are allasync def;App.update()/App.drop()are async; sync entry points (App.update_blocking(),App.drop_blocking(),start_blocking(),stop_blocking()) are available for CLI and blocking contexts
When adding new functionality that involves I/O or concurrency:
- Implement async in Rust
- Bridge to Python via
rust/py, providing both sync and async variants if needed
The current codebase is for CocoIndex v1, which is a fundamental redesign from CocoIndex v0. CocoIndex v1 lives on the main branch; the legacy v0 code is preserved on the v0 branch.
All diagrams embedded in docs pages are built as inline Astro components under docs/src/components/diagrams/, not exported SVG files. See docs/src/components/diagrams/README.md for the palette, primitives, shared CSS classes, animation conventions, and step-by-step authoring guidelines. Follow that guide for any new or updated diagram.
To find where a concept lives, prefer semantic search over blind grep. This repo keeps a local
cocoindex-code index; query it from any agent's shell with the ccc CLI:
ccc search "where is X handled" # semantic search; add --refresh to reindex firstIf ccc search reports no index, set it up once:
command -v ccc >/dev/null || uv tool install 'cocoindex-code[full]' # per machine; local embeddings, no API key
ccc init -f && ccc index # repo-scoped index (first build ~1-2 min)Everything is local — SQLite index, local embeddings, no keys — and a background daemon keeps it
fresh. On a brand-new machine ccc init asks once for an embedding provider; choose the local
default. The -f matters: if a parent directory is also a ccc project, plain ccc init refuses
and search silently uses the parent's index, so run ccc status to confirm the root is this repo.
We register no MCP server on purpose — an ccc mcp mode exists, but the CLI costs no context until
invoked and behaves the same across agents. Source:
github.com/cocoindex-io/cocoindex-code.
Reusable task-specific agent guidance lives under dev/agent-skills/. These playbooks use a SKILL.md format so Claude Code can load them directly from .claude/skills/, but the content is intended to be readable by any coding agent.
- For docs diagrams, read
dev/agent-skills/cocoindex-diagrams/SKILL.md. - For new target connectors, read
dev/agent-skills/target-connector/SKILL.md. - For upgrading example package versions, read
dev/agent-skills/upgrade-examples/SKILL.md.
Agent Skills-compatible clients, including Codex, discover the checked-in symlinks under .agents/skills/. The public CocoIndex skill is exposed there as cocoindex, and contributor playbooks are exposed there from dev/agent-skills/.
Claude Code compatibility remains under .claude/: hooks/settings live there, and .claude/skills/ mirrors the contributor playbooks with symlinks. Do not duplicate skill content under .agents/skills/ or .claude/skills/; edit the source directories (skills/cocoindex/ for the public skill, dev/agent-skills/ for contributor playbooks) and keep dot-directory entries as symlinks.
Portable agent checks live under dev/agent-checks/. Claude Code hooks in .claude/hooks/ are thin adapters that call these scripts when relevant files changed.