CocoIndex supports custom targets: you can export data to any destination, whether it’s a local file, cloud storage, a REST API, or your own bespoke system, and still get incremental updates, change tracking, and automatic cleanup from the engine.
The code in this post uses the CocoIndex v1 API. We ran the complete example below on CocoIndex v1.1.1.
The original version of this post introduced the v0 connector API (TargetSpec, target_connector, get_persistent_key() / apply_setup_change() / mutate()). CocoIndex v1 replaced it with the target-state system: a TargetHandler that reconciles desired state against tracking records. This post describes the v1 approach, with a mapping from the v0 API for readers of the original.
Why you would write one
Agents often read their context from a system CocoIndex has no connector for: an internal knowledge API, a vendor’s ingestion endpoint, a store another team owns. Sending data to it from a plain function is easy. Keeping it in sync with the sources is the hard part:
- Send only what changed. Re-posting every document on each run is slow and can cost money per call.
- Delete what is gone. When a source file is removed, its entry in the API should go too, or agents keep retrieving it.
- Recover from a run that stopped halfway. Some writes landed and some did not, and the next run has to converge anyway.
A custom target gives these jobs to the engine. You write one function that compares the desired state with what was written before, and one function that applies a batch of changes.
Swap targets without rewriting your pipeline
CocoIndex targets share one declarative pattern: you state what should exist, and the connector keeps the external system in sync. Writing HTML files to a folder and rows to Postgres look the same at the call site:
# Local files
localfs.declare_file(outdir / name, html, create_parent_dirs=True)
# Postgres: same declare pattern, different connector
table.declare_row(row=DocEmbedding(id=id, embedding=vec))
Because the transformation logic never touches write paths, swapping a target doesn’t ripple through the pipeline. Custom targets extend the same pattern to systems CocoIndex doesn’t ship a connector for: you implement the reconciliation once, and your pipeline code keeps declaring state the same way. They are the write-side counterpart of custom sources.
What a custom target looks like in v1
A custom target connector is the piece that connects CocoIndex’s declarative target states to an external system. You implement three small things:
- A tracking record: the minimal information persisted per item to detect changes on future runs. For a file, a content fingerprint; for a row, a hash of the row data.
- An action and a sink: the action describes one operation (write this file, delete this row); the
TargetActionSinkexecutes a batch of them against the external system. - A
TargetHandlerwith one required method,reconcile(): given the desired state for a key and the tracking records from previous runs, return an action if something must change, orNoneto skip.
Registration ties it together: coco.register_root_target_states_provider() takes your handler and returns a provider your user-facing API uses to declare target states.
At runtime the engine drives the loop: it collects everything your pipeline declared, calls reconcile() per key (passing NON_EXISTENCE for keys that disappeared, which is how cleanup happens), batches the returned actions by sink, executes them, and persists the new tracking records for next time. reconcile() must be non-blocking and free of side effects; all I/O lives in the sink.
How change detection works
You never write an imperative sync loop. You describe the desired state, and the handler reconciles it against what existed before. To detect changes without re-reading the target, the tracking record stores just enough to compare, typically a content fingerprint from the connectorkits.fingerprint utilities, not the full content. When the fingerprint of the desired state matches the stored record, reconcile() returns None and the item is skipped.
An update can be interrupted after the sink has applied some actions but before the engine commits the new records. The engine then keeps every record the target may reflect until a later run confirms one. That is why reconcile() receives a collection of previous records, and why the skip condition checks that all of them match. A second flag, prev_may_be_missing, is True when the item may not exist in the target at all: a key with no stored record, or one whose interrupted action was a delete. Actions must be idempotent, so that re-applying a write that already landed has no ill effect.
The playground below runs the handler from this post on three output files. Change the sources and tick the interrupted run to see each call’s arguments and return value.
What reconcile() sees on the next run
Fingerprints shortened and made upThe last run wrote three HTML files and stored one fingerprint for each. Change the sources, then read each call. Below the rows, mark the previous run as interrupted.
- guide.mdkey
"data__guide.md.html"- desired_target_state
- bytes 5f3e
- prev_possible_records
- [a41c]
- prev_may_be_missing
- False
returnswritea41c → 5f3e: content changed
- notes.mdkey
"data__notes.md.html"- desired_target_state
- bytes 7d02
- prev_possible_records
- [7d02]
- prev_may_be_missing
- False
returnsNoneskip: every record matches 7d02
- old.mdkey
"data__old.md.html"- desired_target_state
- NON_EXISTENCE
- prev_possible_records
- [e9b5]
- prev_may_be_missing
- False
returnsdeletethe key is no longer declared
_file_sink- _FileAction("data__guide.md.html", content=5f3e)
- _FileAction("data__old.md.html", content=None)
- data__guide.md.html[5f3e]
- data__notes.md.html[7d02]
- data__old.md.htmlremoved
1 write, 1 delete, 1 skipped
With memo=True, an unchanged file's component is skipped before reconcile() runs. The engine also skips the call when it can compare fingerprints itself. The rows show what the handler returns when it is called.
Example: export markdown files to local HTML
The example converts .md files to .html on disk and updates changed files incrementally. The pipeline side is the files_transform example. It uses the built-in localfs target; afterwards we build a simplified version of that file target ourselves.
The pipeline
One processing component per markdown file: read, render, declare the HTML file that should exist.
@coco.fn(memo=True)
async def process_file(file: FileLike, outdir: pathlib.Path) -> None:
html = _markdown_it.render(await file.read_text())
outname = "__".join(file.file_path.path.parts) + ".html"
localfs.declare_file(outdir / outname, html, create_parent_dirs=True)
@coco.fn
async def app_main(sourcedir: pathlib.Path, outdir: pathlib.Path) -> None:
files = localfs.walk_dir(
sourcedir,
path_matcher=PatternFilePathMatcher(included_patterns=["**/*.md"]),
live=True,
)
await coco.mount_each(process_file, files.items(), outdir)
declare_file states what should exist. The connector writes the file, overwrites it on change, and deletes the .html when the source .md disappears, so the pipeline has no file I/O code.
@coco.fn(memo=True): unchanged files are skipped; components run in parallel, one per file.- fingerprint unchanged→skip
- content changed→write
- source .md gone→delete
Build the file target yourself
Here is a simplified version of that localfs connector, following the custom target connector guide. First the imports and types: the value a pipeline declares for one file, an action describing one file operation, and a tracking record holding a content fingerprint.
import pathlib
from collections.abc import Collection, Sequence
from dataclasses import dataclass
from typing import NamedTuple
import cocoindex as coco
from cocoindex.connectorkits.fingerprint import fingerprint_bytes
_FileContent = bytes # the value declared for one file
class _FileAction(NamedTuple):
path: pathlib.Path
content: _FileContent | None # None = delete
@dataclass(frozen=True, slots=True)
class _FileTrackingRecord:
fingerprint: bytes
The sink applies a batch of actions. Every operation is idempotent (missing_ok, exist_ok), so a batch that runs twice leaves the same files:
def _apply_actions(
context_provider: coco.ContextProvider, actions: Sequence[_FileAction]
) -> None:
for action in actions:
if action.content is None:
action.path.unlink(missing_ok=True)
else:
action.path.parent.mkdir(parents=True, exist_ok=True)
action.path.write_bytes(action.content)
_file_sink = coco.TargetActionSink[_FileAction].from_fn(_apply_actions)
The handler holds the reconciliation logic: delete when the state is gone, skip when every possible previous record matches the new fingerprint, write otherwise.
class _FileHandler(coco.TargetHandler[_FileContent, _FileTrackingRecord]):
def __init__(self, base_path: pathlib.Path):
self._base_path = base_path
def reconcile(
self,
key: coco.StableKey,
desired_target_state: _FileContent | coco.NonExistenceType,
prev_possible_records: Collection[_FileTrackingRecord],
prev_may_be_missing: bool,
/,
) -> coco.TargetReconcileOutput[_FileAction, _FileTrackingRecord] | None:
path = self._base_path / str(key)
if coco.is_non_existence(desired_target_state):
if not prev_possible_records and not prev_may_be_missing:
return None
return coco.TargetReconcileOutput(
action=_FileAction(path=path, content=None),
sink=_file_sink,
tracking_record=coco.NON_EXISTENCE,
)
target_fp = fingerprint_bytes(desired_target_state)
if not prev_may_be_missing and all(
prev.fingerprint == target_fp for prev in prev_possible_records
):
return None
return coco.TargetReconcileOutput(
action=_FileAction(path=path, content=desired_target_state),
sink=_file_sink,
tracking_record=_FileTrackingRecord(fingerprint=target_fp),
)
Registering the handler returns a provider. The handler gets its output directory as an argument, and a thin user-facing wrapper turns the provider into the declare_file call the pipeline uses. The pipeline renders HTML as a str, so the wrapper encodes it to the bytes the handler fingerprints:
OUTDIR = pathlib.Path("./output_html")
_provider = coco.register_root_target_states_provider(
"myproject.io/file", _FileHandler(base_path=OUTDIR)
)
def declare_file(filename: str, content: str | bytes) -> None:
if isinstance(content, str):
content = content.encode()
coco.declare_target_state(_provider.target_state(filename, content))
The provider name ("myproject.io/file") must be unique in your app, and the key you pass to target_state() identifies the file across runs.
Use it in the pipeline
The pipeline changes in one line: it calls your declare_file with a file name instead of localfs.declare_file with a path.
from cocoindex.connectors import localfs
from cocoindex.resources.file import FileLike, PatternFilePathMatcher
from markdown_it import MarkdownIt
_markdown_it = MarkdownIt("gfm-like")
@coco.fn(memo=True)
async def process_file(file: FileLike) -> None:
html = _markdown_it.render(await file.read_text())
outname = "__".join(file.file_path.path.parts) + ".html"
declare_file(outname, html)
@coco.fn
async def app_main(sourcedir: pathlib.Path) -> None:
files = localfs.walk_dir(
sourcedir,
path_matcher=PatternFilePathMatcher(included_patterns=["**/*.md"]),
live=True,
)
await coco.mount_each(process_file, files.items())
app = coco.App(
coco.AppConfig(name="CustomFileTarget"),
app_main,
sourcedir=pathlib.Path("./data"),
)
Containers (a directory whose files are children, a table whose rows are children) follow the same pattern one level deeper: the parent’s sink is built with a child_handler factory that returns the handler for the children. The connector guide covers that, along with attachments for auxiliary state like vector indexes.
Run it
Put the code blocks from Build the file target yourself and Use it in the pipeline in one main.py, add a few .md files under data/, and run:
pip install "cocoindex>=1.1.1" "markdown-it-py[linkify,plugins]"
echo "COCOINDEX_DB=./cocoindex.db" > .env
cocoindex update main # catch-up: scan, sync, exit
cocoindex update -L main # live: keep watching for file changes
We ran it with three files, then edited guide.md and deleted old.md. The second run’s summary reports what changed:
✅ process_file: 3 total | 1 reprocessed, 1 deleted, 1 unchanged
The sink rewrote output_html/data__guide.md.html and deleted output_html/data__old.md.html. notes.md was unchanged, so its component was skipped. Live mode keeps the output synchronized as files change, which suits content that changes often, such as an internal wiki.
From the v0 connector API
If you wrote a v0 custom target, here is where each piece went:
| v0 connector API | v1 equivalent |
|---|---|
TargetSpec | Plain value types declared per target state |
get_persistent_key() | The StableKey per state, with ContextKey for external resources |
apply_setup_change() | The container target’s reconcile() and sink |
mutate() | Per-item reconcile() plus batched actions in the sink |
| Automatic tracking | A tracking record you define (usually a fingerprint) |
prepare() | Handler construction, or context_provider at action time |
The v0 API split targets into setup (DDL-like) and data (DML-like) methods. The v1 model unifies both as target states reconciled the same way, which is what makes containers, children, and attachments compose: a table and its rows are the same concept at two levels.
Connector design rules
- Idempotent actions: actions should be safe to run more than once (
ON CONFLICT DO UPDATE,missing_ok=True,exist_ok=True), because interrupted runs re-apply them, and a failed batch is retried in smaller batches. - Keep tracking records minimal: a fingerprint beats storing content; the engine persists them per item across runs.
- Handle multiple previous states: skip only when all
prev_possible_recordsmatch andprev_may_be_missingis false. - Use
ContextKeyfor external resource identity: put the stable logical name in the target state key, never hosts or credentials, so rotating a password doesn’t orphan tracked state.
When to build a custom target
Most pipelines use built-in connectors and never need one. Reach for a custom target connector when you integrate with a system CocoIndex doesn’t cover, need custom change detection (such as content-based fingerprinting), or manage hierarchical state (containers with children). For a simple “write this somewhere” step with no cleanup requirement, a regular memoized function is often enough. A connector is worth writing when you want the engine to track and clean up what it wrote.
If you build a connector for a system others use too, tell us about it: CocoIndex on GitHub.
Frequently asked questions
What is a custom target in CocoIndex?
A custom target connector connects CocoIndex's declarative target-state system to an external system. You implement three pieces: a tracking record (minimal per-item state for change detection, typically a fingerprint), an action plus a TargetActionSink that executes batches of operations, and a TargetHandler whose reconcile() compares desired state against previous records and returns an action only when something must change.
How do custom targets get incremental updates and cleanup?
You declare what should exist; the engine collects the declarations, calls reconcile() per key, and passes NON_EXISTENCE for keys no longer declared, which is how deletions happen automatically. Tracking records let the handler skip unchanged items by comparing fingerprints instead of re-reading the target. Because an interrupted run can leave several possible previous states, reconcile() receives a collection of records plus a prev_may_be_missing flag, and actions must be idempotent so that re-applying them converges.
What happened to TargetSpec, get_persistent_key, apply_setup_change, and mutate from v0?
The v0 connector interface was replaced in CocoIndex v1 by the target-state system. TargetSpec becomes plain value types, get_persistent_key() becomes the stable key of each target state (with ContextKey for external resources), apply_setup_change() becomes the container target's reconcile() and sink, and mutate() becomes per-item reconcile() with batched actions executed by the sink.
When should I build a custom target connector?
When you integrate with a system CocoIndex has no built-in connector for, need custom change detection such as content-based fingerprinting, or manage hierarchical target states (containers with children). For a simple write with no cleanup requirement, a regular memoized function is often enough; the connector machinery is most valuable when the engine should track and clean up what it wrote.
How do containers like tables and directories work?
Containers follow the same handler pattern one level deeper: the parent target (directory, table) reconciles itself, and its sink is built with a child_handler factory that returns the handler for the children (files, rows). Children then reconcile independently with their own tracking records. Attachments extend this with auxiliary state such as vector indexes, tracked separately from regular children.
How should I design a target connector?
Keep actions idempotent (ON CONFLICT DO UPDATE, missing_ok=True) because interrupted runs re-apply them. Keep tracking records minimal, usually a fingerprint rather than full content. Skip an item only when all possible previous records match and prev_may_be_missing is false. Use a ContextKey logical name in target state keys instead of hosts or credentials, so rotating a password doesn't orphan tracked state.
Build fresh context for your agents.
CocoIndex keeps derived data in sync with its sources. Only what changed re-runs.
