* feat(extensions): add hindsight-extensions registry and unbundle Supabase
Extensions were only ever bundle-able or nothing: shipping one meant putting
it in `hindsight_api.extensions.builtin`, where it becomes maintainer-owned
forever, lands in every image, and — because `extensions/__init__.py` eagerly
re-exported every implementation — drags its dependencies into core's import
graph. That pipe is why a third-party IdP's JWT client was a direct dependency
of every Hindsight install.
Add `hindsight-extensions/` as the registry for extensions distributed
separately from the server. Its README is the contract: slots and how config
env vars map onto them, how to write an extension, the package layout and
naming (`hindsight-extensions/<name>/` -> `hindsight-ext-<name>` ->
`hindsight_ext_<name>`), and Docker packaging.
Move `SupabaseTenantExtension` there as the first entry, published as
`hindsight-ext-supabase-tenant`. All 54 of its tests move with it, plus two new
ones asserting the documented `hindsight_ext_supabase_tenant:...` env value
actually resolves through `load_extension`.
Two decisions worth their comments:
- The extension does NOT declare `hindsight-api-slim` as a runtime dependency.
The server is the host process that imports it, not something it installs;
declaring it would let `pip install` of an extension silently move the server
version underneath a running deployment. It is a dev extra, resolved from the
local checkout via `tool.uv.sources` (dev-only metadata, verified absent from
the built wheel).
- The Docker example installs with `uv pip install --python
/app/api/.venv/bin/python`, matching docker-compose/custom-models: the image's
venv was created by `uv sync` and ships no `pip`, so a bare `pip install`
lands in user site-packages and is invisible to the server.
Core changes:
- `extensions/__init__.py` and `builtin/__init__.py` export interfaces only.
Nothing needed the concrete re-exports — the loader imports by path — and
dropping them is what lets an extension have optional dependencies at all.
- `builtin/supabase_tenant.py` stays for one minor release as a module whose
`__getattr__` raises the migration instructions. `load_extension` wraps a
missing *attribute*, not a failed import, so the ImportError propagates with
its message intact instead of surfacing as "class not found".
- Drop the direct `PyJWT[crypto]` dependency: no core module imports `jwt` any
more. Note this does not shrink the install — `mcp` pulls pyjwt transitively
and `cryptography` is already pinned directly — so the win here is ownership
and import graph, not bytes.
Locks are not checked in for extensions: `tool.uv.sources` pins the whole
api-slim tree, so every core dependency bump would leave them stale. CI runs
`uv sync --extra dev` and retriggers on `core` changes, since these tests run
against the server's interfaces.
Docs point at the registry rather than restating it, and the Deploying section's
Docker recipe was replaced — it named an image (`vectorize/hindsight-api`) and a
PYTHONPATH volume-mount pattern that no longer exist.
Also includes two one-line generated-file syncs in skills/hindsight-docs
(quickstart, installation) that were already stale on main; regenerating the
docs skill picks them up.
* refactor(extensions): ship extensions by image, drop the compat shim
Follow-up on review. Three changes to how an extension is distributed:
- Delete `builtin/supabase_tenant.py`. An install pinned to the old path now
fails at startup with ModuleNotFoundError rather than a guided message. The
docs carry the migration instead.
- Extensions are not published to PyPI. There is no wheel, no version and no
release step: the unit of distribution is an image built on top of Hindsight
that installs the extension's dependencies and copies the package onto
PYTHONPATH. That drops the whole "declare hindsight-api-slim only as a dev
extra" problem — nothing resolves dependencies against a running server any
more.
- The pyproject is now test-harness only (`package = false`, no build backend,
no distribution metadata), and says so in a comment so nobody re-adds
packaging to it.
Docs say 0.9.3, not 0.10.
Since the Dockerfile is now the distribution mechanism rather than an example,
CI builds it — its final `import` step is the only thing proving the extension
is reachable from the interpreter the server actually runs. It builds against
`:latest-slim` via a HINDSIGHT_IMAGE build arg to keep the pull cheap.
Verified against the real image, not just locally:
docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
--build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim ...
-> load_extension('TENANT', TenantExtension) inside the container returns
SupabaseTenantExtension with its config resolved from the env vars.
Worth noting from that build: `uv pip install 'PyJWT[crypto]' httpx` reports
"Checked 2 packages" — both are already in the base image transitively. The
line stays because the extension should pin what it imports rather than rely on
the server's transitive tree, but it costs nothing today.
56 extension tests pass; the 3 remaining core tests (which assert no
implementation is re-exported and no core module imports jwt) pass.
* fix(tests): import ApiKeyTenantExtension from its module, not the package
Dropping the concrete re-exports from `hindsight_api.extensions` broke
`tests/test_extensions.py`, which imported `ApiKeyTenantExtension` from the
package inside a multi-line parenthesised import. A collection ImportError
fails the whole shard, which is why all three test-api shards and all six LLM
acceptance jobs went red at once on the previous push.
I'd checked for this with a single-line grep, which cannot see a name inside a
parenthesised import list. Re-checked with an AST scan over every package in
the repo (this was the only occurrence) and by collecting the full suite:
7780 tests collect clean.
Hindsight Extensions
Extensions customise the Hindsight API server without forking it: multi-tenancy and authentication, extra HTTP endpoints, extra MCP tools, and hooks around retain/recall/reflect. They are ordinary Python packages that the server imports by path at startup.
This directory is the registry. Each subdirectory is an extension that lives outside the server, so installing Hindsight does not drag in a third-party vendor's client library and changing an extension does not require a Hindsight release.
Extensions here are not published to PyPI. You ship one by building an image on top of Hindsight that copies the extension in — see Packaging.
Registry
| Extension | Slot | What it does |
|---|---|---|
supabase-tenant |
TENANT |
Validates Supabase Auth JWTs and gives each user their own Postgres schema |
Extensions maintained outside this repository can be listed here too — open a PR adding a row that links to yours.
What stays in the server
Two extensions ship with hindsight-api-slim because they add no dependencies and
any deployment may want them:
hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension— single shared API key, single schema.hindsight_api.extensions.builtin.memory_defense_regex:MemoryDefenseRegexExtension— regex-based memory defense policies.
Anything that talks to a specific vendor, identity provider, or deployment style belongs here instead.
Extension slots
The server loads at most one extension per slot, each from its own environment
variable in module.path:ClassName form:
| Slot | Environment variable | Base class |
|---|---|---|
| Tenancy / auth | HINDSIGHT_API_TENANT_EXTENSION |
TenantExtension |
| HTTP endpoints | HINDSIGHT_API_HTTP_EXTENSION |
HttpExtension |
| MCP tools | HINDSIGHT_API_MCP_EXTENSION |
MCPExtension |
| Operation hooks | HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION |
OperationValidatorExtension |
| Memory defense | HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION |
MemoryDefenseExtension |
Every other environment variable sharing the slot's prefix becomes the extension's
config, lowercased and with the prefix stripped. For the TENANT slot:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co # -> config["supabase_url"]
HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user # -> config["schema_prefix"]
There is nothing to register: if the class is importable in the server's Python environment and subclasses the slot's base class, it loads.
Writing an extension
# hindsight_ext_myauth/extension.py
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
class MyTenantExtension(TenantExtension):
def __init__(self, config: dict[str, str]) -> None:
super().__init__(config)
self.secret = config.get("secret")
if not self.secret:
raise ValueError("HINDSIGHT_API_TENANT_SECRET is required")
async def on_startup(self) -> None:
"""Open clients, warm caches. Raise to stop the server booting misconfigured."""
async def authenticate(self, context: RequestContext) -> TenantContext:
if context.api_key != self.secret:
raise AuthenticationError("Invalid API key")
return TenantContext(schema_name="my_tenant")
async def list_tenants(self) -> list[Tenant]:
"""Schemas the background worker should process."""
return [Tenant(schema="my_tenant")]
async def on_shutdown(self) -> None:
"""Close what on_startup opened."""
Things worth knowing before you write one:
- Validate config in
__init__. A misconfigured extension should fail the server's startup, not the first request that hits it. self.contextis anExtensionContext, the supported API into the server. For tenant extensions the important call isawait self.context.run_migration(schema), which provisions a new tenant schema. Cache the schemas you have already migrated —authenticateruns on every request.list_tenants()drives the background worker. A schema you never return gets no consolidation or maintenance, so returning only schemas seen since the last restart means tenants go stale until they are used again.- Extensions run in-process, inside the auth boundary. A
TenantExtensiondecides which tenant's data a request can reach; treat schema names derived from user input as untrusted and validate their shape before they reach a schema name.
The interfaces live in hindsight-api-slim/hindsight_api/extensions/; each base class
documents the full method set.
Packaging an extension
Layout — one directory per extension, mirroring supabase-tenant/:
hindsight-extensions/<name>/
├── pyproject.toml # test harness only — not a published distribution
├── README.md # config reference + how to build an image with it
├── Dockerfile # the image that ships it
├── hindsight_ext_<name>/
│ ├── __init__.py # re-export the class for a short import path
│ └── extension.py
└── tests/
Naming keeps the env var short and unambiguous:
| Directory | hindsight-extensions/supabase-tenant/ |
| Import package | hindsight_ext_supabase_tenant |
| Env value | hindsight_ext_supabase_tenant:SupabaseTenantExtension |
There is no version number, no wheel and no release step. The unit of distribution is the image you build, so the extension is exactly as current as the checkout you built it from.
The pyproject is for tests, not packaging
The pyproject.toml exists so uv run pytest works. It declares package = false,
so uv puts the dependencies in a virtualenv and leaves the sources on the path without
building anything:
[project]
name = "hindsight-ext-<name>"
version = "0"
requires-python = ">=3.11"
dependencies = [
"PyJWT[crypto]>=2.12.0", # whatever your extension imports
"httpx>=0.27.0",
"hindsight-api-slim", # test-time only: the interfaces you write against
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
]
[tool.uv]
package = false
[tool.uv.sources]
hindsight-api-slim = { path = "../../hindsight-api-slim", editable = true }
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
asyncio_mode = "auto"
hindsight-api-slim belongs here and only here. At runtime the server is the host
process that imports your extension, not something your extension installs — an
extension that pulled the server in as a dependency could move the server version
underneath the deployment it was being added to.
Develop and test
From the extension directory:
uv sync # deps plus the server from ../../hindsight-api-slim
uv run pytest tests -v
Extension tests are plain unit tests — construct the class with a config dict, mock
whatever it talks to, and assert on the TenantContext / ValidationResult it
returns. They do not need a database.
To run a real server against your extension without building an image:
cd ../../hindsight-api-slim
PYTHONPATH=../hindsight-extensions/myauth \
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_myauth:MyTenantExtension \
HINDSIGHT_API_TENANT_SECRET=dev-secret \
uv run hindsight-api
Docker packaging
The Hindsight image does not carry extensions. Ship yours by building an image on top of it that installs the extension's dependencies and copies the extension in.
FROM ghcr.io/vectorize-io/hindsight:latest
# Install into the server's virtualenv explicitly. It was created by `uv sync`
# and ships no `pip` of its own, so a bare `pip install` would land in user
# site-packages and be invisible to the running server.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
'PyJWT[crypto]>=2.12.0' \
'httpx>=0.27.0'
# /app/extensions is ours — the image does not use it — so nothing the server
# ships can be shadowed by what lands here.
COPY hindsight-extensions/supabase-tenant/hindsight_ext_supabase_tenant \
/app/extensions/hindsight_ext_supabase_tenant
ENV PYTHONPATH=/app/extensions
# Fail the build, rather than the first authenticated request.
RUN /app/api/.venv/bin/python -c "import hindsight_ext_supabase_tenant"
That last import line is the one worth keeping: without it a packaging mistake ships
happily and only surfaces when a request reaches the extension.
Build from the repository root, so the extension sources are in the build context:
docker build -f hindsight-extensions/supabase-tenant/Dockerfile -t hindsight-with-supabase .
docker run -p 8888:8888 \
-e HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension \
-e HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co \
-e HINDSIGHT_API_DATABASE_URL=postgresql://... \
hindsight-with-supabase
Use ghcr.io/vectorize-io/hindsight:latest-slim as the base if you do not need the
bundled local embedding/reranking models.
Same shape in docker-compose.yml — build the image and pass the extension's variables
as environment:
services:
hindsight-api:
build:
context: . # repository root
dockerfile: hindsight-extensions/supabase-tenant/Dockerfile
environment:
HINDSIGHT_API_TENANT_EXTENSION: hindsight_ext_supabase_tenant:SupabaseTenantExtension
HINDSIGHT_API_TENANT_SUPABASE_URL: https://xxx.supabase.co
A worker deployment loads the same tenant extension as the API, so give both containers the identical extension variables — otherwise the worker cannot enumerate tenant schemas and background consolidation stops for every tenant.
Do not install extensions at container start (an entrypoint that runs
pip install). That resolves unpinned code over the network into a running server on every restart.
Contributing an extension
Open a PR adding hindsight-extensions/<name>/ with the layout above:
- a
README.mddocumenting every environment variable it reads, - tests that exercise the extension through its base-class interface,
- a
Dockerfilethat builds an image with it, - a row in the registry table above.
Extensions here are owned by their contributors. If you would rather host yours yourself, add a registry row pointing at your repository and package — no code needs to live in this tree.