Authoring a Plugin
A plugin is an ordinary Python distribution that adds a new backend and advertises it through a Python entry point. The backend can be a parser, LLM, VLM, embedder, store, output writer, or a whole pipeline stage.
Once a user runs pip install your-package, indx discovers the backend lazily and it becomes usable by name, exactly like a built-in. There’s no fork of indx and no edits to the core.
The recipe below uses a worked example, indx-weaviate, which adds a Weaviate vector store.
How discovery works
Section titled “How discovery works”indx keeps a per-slot registry that maps a short name (the string you write in config) to a class. First-party builtins are registered lazily inside indx; third-party backends are found at runtime through importlib.metadata entry points.
The first time a slot is resolved, the registry scans these entry-point groups and merges anything it finds into that slot’s registry:
| Entry-point group | Slot | Protocol it must satisfy |
|---|---|---|
indx.parsers | parser | Parser |
indx.llms | llm | LLM |
indx.vlms | vlm | VLM |
indx.embedders | embedder | Embedder |
indx.stores | store | Store |
indx.outputs | output | OutputWriter |
indx.stages | pipeline | Stage |
The protocol signatures are defined in the protocols reference. The registry and the full list of built-in names live in registry and defaults.
The example package layout
Section titled “The example package layout”A plugin uses the standard src-layout — just the adapter module plus a pyproject.toml:
indx-weaviate/├── pyproject.toml└── src/ └── indx_weaviate/ ├── __init__.py └── store.py # class WeaviateStore — satisfies the Store protocolThe adapter implements the relevant protocol structurally — no base class to subclass, you just match the method signatures. For a store, that means upsert, search, and delete:
from __future__ import annotations
from indx import SearchHitfrom indx.core.chunk import Chunkfrom indx.errors import MissingDependencyError
class WeaviateStore: """Vector store backed by Weaviate. Satisfies the Store protocol."""
def __init__(self, url: str = "http://localhost:8080") -> None: # Keep construction cheap; defer the real client to first use. self.url = url self._client = None
def _connect(self) -> None: try: import weaviate # lazy: only when actually used except ModuleNotFoundError as exc: raise MissingDependencyError( "The Weaviate store requires its dependency. " "Install it with: pip install indx-weaviate[weaviate]" ) from exc self._client = weaviate.connect_to_local(self.url)
def upsert(self, chunks: list[Chunk]) -> None: if self._client is None: self._connect() # Convert core chunks -> Weaviate objects HERE, at the edge. ready = [c for c in chunks if c.embedding is not None] ...
def search( self, vector: list[float], k: int = 5, ) -> list[SearchHit]: if self._client is None: self._connect() # Convert vendor results back into SearchHit(chunk=..., score=...). ...
def delete(self, chunk_ids: list[str]) -> None: if self._client is None: self._connect() ...Registering the entry point
Section titled “Registering the entry point”The single line that makes your backend discoverable is a [project.entry-points."<group>"] table in pyproject.toml. The left-hand side is the registry name users will type; the right-hand side is the module:Class target.
[project]name = "indx-weaviate"version = "0.1.0"requires-python = ">=3.11"dependencies = ["indx"]
[project.optional-dependencies]# Declare the heavy/optional backend dependency as an extra named after the# registry key, so users can `pip install indx-weaviate[weaviate]`.weaviate = ["weaviate-client>=4.0"]
[project.entry-points."indx.stores"]weaviate = "indx_weaviate.store:WeaviateStore"That weaviate = "indx_weaviate.store:WeaviateStore" line is the whole contract. It says: register the class WeaviateStore from indx_weaviate.store under the name weaviate in the store slot.
A package may register several backends across several slots — just add more lines, or more [project.entry-points."..."] tables (such as an indx.parsers table alongside indx.stores).
Using it after install
Section titled “Using it after install”After pip install indx-weaviate, the name weaviate is available everywhere a built-in store name is, with no change to indx itself. In configuration:
[store]backend = "weaviate"Or directly in the SDK:
from indx import DirectoryPipeline
pipeline = DirectoryPipeline(store="weaviate")space = pipeline.run("./docs", "./ai-ready")Or on the CLI:
indx ./docs --out ./ai-ready --store weaviateWhen it resolves the name, the registry validates that WeaviateStore actually satisfies the Store protocol. If it doesn’t, resolution fails loudly with an actionable error rather than failing deep inside a stage.
The authoring contract
Section titled “The authoring contract”Every backend, whether built-in or third-party, follows the same fixed recipe. Plugins must additionally be safe to discover.
Rule 1: Implement the protocol exactly
Section titled “Rule 1: Implement the protocol exactly”Match the method signatures in the protocols reference precisely. Structural typing means you don’t inherit from anything — you satisfy the interface. The @runtime_checkable protocols are available if you want an isinstance check in your own tests.
Rule 2: Accept config the standard way
Section titled “Rule 2: Accept config the standard way”Accept an optional model/config keyword and read backend-specific options from the matching indx.toml sub-table. For a store named weaviate, indx passes the contents of [store.weaviate] verbatim to your constructor; those keys are opaque to the core.
[store]backend = "weaviate"
[store.weaviate]url = "http://localhost:8080" # passed straight to WeaviateStore(url=...)Rule 3: Be import-safe
Section titled “Rule 3: Be import-safe”Defer heavy or optional dependencies to construction or method-call time, never to module import.
Discovery imports your module to read the class. If that import triggers import weaviate at the top level, a user who installed your plugin but not its backend would see unrelated runs break. Import inside the method, and raise MissingDependencyError with a pip install hint, as shown in the example above.
Rule 4: Keep stores portable
Section titled “Rule 4: Keep stores portable”For a Store, accept and return core objects only:
upsert(chunks)receives chunks whose.embeddingfield is already populated.search(vector, k)returnsSearchHitobjects.delete(chunk_ids)removes ids.
The writer materializes vectors into the standard embeddings/ layout, so a sealed .indx archive remains portable regardless of which backend produced it. See the .indx archive format.
Rule 5: Custom stages return the same context
Section titled “Rule 5: Custom stages return the same context”If you ship a Stage under indx.stages, its run(ctx) must return the same SpaceContext instance it received, mutated in place. Append per-item failures to ctx.errors rather than raising, unless the failure is genuinely fatal. See Custom Stage for the full pattern.
Start from the template
Section titled “Start from the template”A worked, copy-paste plugin example lives in the indx repository under examples/custom_plugin/. It is a teaching-example parser plugin: a single adapter module indx_uppercase_parser.py defining UppercaseParser (which structurally satisfies the Parser protocol), plus a pyproject.toml that registers it under the indx.parsers entry-point group (uppercase = "indx_uppercase_parser:UppercaseParser").
Clone it, rename the package and registry name, fill in the protocol methods, and publish.
# from a checkout of https://github.com/indx/indxcp -r examples/custom_plugin ../indx-mybackendVersioning guarantees
Section titled “Versioning guarantees”Plugins are insulated from churn by indx’s compatibility promise:
- Protocols and the
index.json/.indxschemas are versioned with indx’s major version. Within a major version, fields are only added, never removed or retyped. - Additive changes are safe. New
RelationTypemembers ormetadatakeys may appear in a minor release. Well-behaved adapters ignore values they do not recognize rather than failing. - An adapter built against an earlier minor version keeps working against later minors of the same major version. A major version bump is the only place a protocol may change incompatibly.
Pin your dependency accordingly, for example dependencies = ["indx>=0.4,<1.0"]. Your backend then continues to resolve and run across minor upgrades.
Where to next
Section titled “Where to next”- Custom Components — pass a backend as an in-process object without packaging it.
- Custom Stage — insert your own work into the pipeline.
- Registry and Defaults — every built-in name and how resolution precedence works.
- Protocols — the exact method signatures your backend must satisfy.
- Adding a Backend — contributing a backend to indx core instead of shipping a separate package.