A plugin is a Python package that repowise imports at startup. It adds CLI
commands, MCP tools, pipeline hooks, model providers, or an implementation of a
named seam (an event sink, a report section, an authenticator, and so on),
without a fork. The working example is
examples/plugins/hello; this page explains
each step.
The plugin API is experimental. Read Stability before you publish.
| Goal | Register with | Kind in repowise plugins |
|---|---|---|
| Add a CLI command | register_command(cmd) or register_lazy_command(name, "module:attr") |
cli-command |
| Add an MCP tool | mcp_tool_registry.register(default=False)(fn) |
mcp-tool |
| Run code before or after a pipeline phase | register_hook(phase, callback, when="pre"|"post") |
pipeline-hook |
| Add an LLM provider | register_provider(name, factory) |
llm-provider |
| Add an embedding provider | register_embedder(name, factory) |
embedder |
| Add or replace an implementation of a seam | <seam registry>.register(name, impl) |
the seam name, for example event-sink |
Every row is listed in the extension points catalog.
| Idea | Uses | What it does |
|---|---|---|
| Slack or Teams notifier | event-sink |
Posts component events (a new hotspot, an ownership shift) to a channel |
| Webhook or SIEM forwarder | event-sink |
Sends each event as CloudEvents JSON to an HTTP endpoint or a log pipeline |
| Self-hosted model gateway | llm-provider |
Routes wiki generation and chat through your own model gateway |
| Local embedding model | embedder |
Embeds pages with a model that runs on your machines |
| LDAP or Okta teams | team-directory |
Reads team membership from your directory for declared ownership |
SSO for repowise serve |
authenticator |
Accepts your identity provider's tokens on the HTTP API |
| Jira or Linear links | change-metadata |
Fills linked issues, reviewers and labels on change records from your tracker |
| Internal vulnerability feed | advisory-source |
Reads advisories from your own feed for repowise packages import |
| Compliance report section or format | report-section, report-renderer |
Adds evidence your auditors ask for, or writes the report in their text format |
| Internal API as an agent tool | mcp-tool |
Gives agents a read-only tool that queries an internal service |
| Team shortcut command | cli-command |
Wraps a repowise workflow your team runs often in one command |
| Phase timings | pipeline-hook |
Records when each index phase starts and ends and sends the timings to your metrics system |
| Extract an unsupported document type | document-extractors |
Converts a plugin-supported file type to Markdown for the existing ingestion path |
-
Write a module with a
register()function. repowise calls it once per process, with no arguments.# src/repowise_hello/__init__.py import click from repowise.core.registry import register_command @click.command("hello") def hello_command() -> None: """Say hello.""" click.echo("hello") def register() -> None: register_command(hello_command)
-
Declare an entry point in the
repowise.pluginsgroup. The value is a module (imported, nothing called) ormodule:callable(imported, then called).# pyproject.toml [project] name = "repowise-hello" version = "0.1.0" dependencies = ["repowise>=0.59"] [project.entry-points."repowise.plugins"] hello = "repowise_hello:register"
-
Install it into the environment repowise runs from.
pip install -e .
For local work without installing, list targets in REPOWISE_PLUGINS
(comma-separated, same module or module:callable form). The module must be
importable, so put its source directory on PYTHONPATH:
PYTHONPATH=src REPOWISE_PLUGINS=repowise_hello:register repowise plugins| Source | Form | Read from |
|---|---|---|
| Entry point | name = "module" or name = "module:callable" in group repowise.plugins |
installed package metadata |
REPOWISE_PLUGINS |
module or module:callable, comma-separated |
the process environment only, never a repository's .repowise/.env |
A target listed both ways loads once. Register inside register(), not at
module import time, so a test can import your module without side effects.
repowise plugins
repowise plugins --format jsonThe listing shows each plugin with its source, target, status (loaded,
failed with the error, or skipped for a duplicate) and everything it
registered as "<kind>:<name>" strings. It then lists every seam with the
implementation get() picks and all registered names. The JSON form carries
the same data:
{
"api_version": 1,
"plugins": [
{
"name": "hello",
"source": "entry point",
"target": "repowise_hello:register",
"status": "loaded",
"error": null,
"seams": ["cli-command:hello", "mcp-tool:hello_greet", "event-sink:hello-stderr", "report-section:hello"]
}
],
"seams": {
"event-sink": {"default": "jsonl", "implementations": ["jsonl", "stdout", "hello-stderr"]}
}
}api_version is PLUGIN_API_VERSION from repowise.core.registry. Check it
in your plugin if you depend on a contract that may change:
from repowise.core.registry import PLUGIN_API_VERSION
if PLUGIN_API_VERSION != 1:
raise RuntimeError(f"repowise-hello supports plugin API 1, found {PLUGIN_API_VERSION}")An exception raised from register() (or at import) marks the plugin
failed. repowise records it and carries on without it.
Plugins load once per process, the first time repowise needs them:
| Moment | What triggers loading |
|---|---|
| CLI | an unknown command name or a command listing (repowise --help); a built-in command runs without loading plugins |
| MCP server | server start, before tools attach |
repowise serve |
app creation |
| Index and update | each pipeline run, before hooks are wrapped |
| A seam | get() or all() on any seam |
| A provider | a lookup of an LLM provider or embedder name that is not built in |
"One" means repowise uses a single selected implementation. "Every" means repowise runs each registered implementation in registration order.
| Kind | Register with | One or every | When it runs | On failure | Defined in |
|---|---|---|---|---|---|
cli-command |
register_command(cmd, parent=None), register_lazy_command(name, "module:attr", parent=None) from repowise.core.registry |
every | when the command is invoked | a plugin command whose name a built-in already holds is ignored with a warning | packages/core/src/repowise/core/registry/cli_registry.py |
mcp-tool |
mcp_tool_registry.register(...) from repowise.core.registry |
every, filtered by tool selection | when an MCP client calls the tool | invalid tier/safety metadata raises ValueError at registration, so the plugin is recorded failed (registrations made before it stay) |
packages/core/src/repowise/core/registry/mcp_tool_registry.py |
pipeline-hook |
register_hook(phase, callback, when=...) from repowise.core.registry |
every | before (pre) or after (post) the named phase of repowise init and repowise update |
an exception is logged and the pipeline continues | packages/core/src/repowise/core/registry/pipeline_hooks.py |
llm-provider |
register_provider(name, factory) from repowise.core.providers.llm |
one, by name | when that provider name is selected | a name that a built-in provider holds raises ValueError |
packages/core/src/repowise/core/providers/llm/registry.py |
embedder |
register_embedder(name, factory) from repowise.core.providers.embedding |
one, by name | when that embedder name is selected | a name that a built-in embedder holds raises ValueError |
packages/core/src/repowise/core/providers/embedding/registry.py |
parent= attaches a command under an existing Click group instead of the
root. For register_lazy_command, name must equal the Click command's own
name: the CLI lists it before importing the module.
Plugin MCP tools join the server's registered surface. Register with
default=False (an opt-in tool) and users enable it with
repowise mcp --tools "+your_tool" or mcp: {tools: ["+your_tool"]} in
.repowise/config.yaml. A tool registered with default=True joins every
default surface, which costs every agent context.
Phase names a hook can target include traverse, parse, graph.metrics,
git, co_change, dead_code, decisions, health, doc_drift,
generation and embedding. A phase that a run skips fires no hooks. The
callback is a plain synchronous function that receives the phase name as its
only argument; an async def callback never runs its body.
from repowise.core.registry import register_hook
def after_parse(phase: str) -> None:
print(f"{phase} finished")
def register() -> None:
register_hook("parse", after_parse, when="post")An LLM provider factory receives the keyword arguments repowise builds for it
(model, plus api_key or base_url when set) and returns a
BaseProvider (repowise.core.providers.llm.base). An embedder factory
returns an Embedder (repowise.core.providers.embedding.base). Users select a
registered provider by name like a built-in one, for example
REPOWISE_PROVIDER=<name> or REPOWISE_EMBEDDER=<name>.
Each seam is a SeamRegistry instance. Import the registry object from the
module in the last column and call .register(name, impl), for example
from repowise.core.events.sinks import event_sinks.
| Seam | Registry object | One or every | When it runs | On failure | Defined in |
|---|---|---|---|---|---|
event-sink |
event_sinks |
every sink named in events.sinks (default: the seam default, jsonl) |
after an index run stores component events | logged; the events stay in the store | packages/core/src/repowise/core/events/sinks.py |
index-persistence |
index_persistence |
every | apply inside each index write's transaction, before commit, including init's checkpoint (mode checkpoint); publish after commit and cleanup, except after the checkpoint |
an apply failure rolls back and propagates, except in the checkpoint, which logs it and leaves the final write to apply; a publish failure propagates and the commit stays; a cleanup failure after the commit skips publish |
packages/core/src/repowise/core/pipeline/index_persistence.py |
report-section |
report_sections |
every | repowise report builds a document |
the section becomes one inconclusive item marked failed; other sections run |
packages/core/src/repowise/core/report/model.py |
report-renderer |
report_renderers |
one, chosen by repowise report assessment --format |
when a report is written | propagates | packages/core/src/repowise/core/report/render.py |
change-outcome |
change_outcome_providers |
every | when a change record's outcomes are read | OutcomeUnknownError marks the outcome unknown; any other exception propagates |
packages/core/src/repowise/core/changes/outcomes.py |
change-metadata |
change_metadata_sources |
one | when change records are stored | propagates | packages/core/src/repowise/core/changes/source.py |
suspect-signal |
suspect_signals |
every | repowise suspects ranks candidate changes |
that signal does not fire; the others still rank | packages/core/src/repowise/core/suspects/rank.py |
advisory-source |
advisory_sources |
one | repowise packages import reads an advisory file |
propagates | packages/core/src/repowise/core/inventory/advisories.py |
document-extractors |
document_extractors |
every; later extension claim wins | During traversal and source reads for otherwise unsupported extensions | indexing propagates with plugin name and original path; optional previews omit unavailable content | packages/core/src/repowise/core/ingestion/document_extractors.py |
team-directory |
team_directories |
one | when components and declared owners load | propagates | packages/core/src/repowise/core/ownership/directory.py |
authenticator |
authenticators |
one | each repowise serve request and MCP HTTP request |
propagates | packages/server/src/repowise/server/auth.py |
authorizer |
authorizers |
one | each repository or tool access check in repowise serve, MCP over HTTP and chat |
propagates | packages/server/src/repowise/server/auth.py |
chat-tools |
tool_backends |
one | each chat turn builds its tool backend | propagates | packages/server/src/repowise/server/chat_seams.py |
chat-store |
conversation_stores |
one | each chat request opens its conversation store | propagates | packages/server/src/repowise/server/chat_seams.py |
chat-hooks |
turn_hooks |
one | each chat turn (preflight, usage, events) | an on_event failure ends the turn with an error; preflight and on_usage failures propagate |
packages/server/src/repowise/server/chat_seams.py |
chat-dataset |
datasets |
every | the chat chart tool lists or loads a dataset |
returned to the model as a tool error; the turn continues | packages/server/src/repowise/server/chat_datasets/__init__.py |
chat-view |
view_builders |
every | chat builds a view of a tool result | logged and returned to the model as a tool error; the turn continues | packages/server/src/repowise/server/chat_views.py |
"Propagates" means the exception reaches the caller: the command, request or
chat turn fails with it. get(name) with a name no implementation holds raises
KeyError that lists the registered names; repowise never falls back to the
local default silently.
Each module defines the protocol its implementations follow next to the
registry, for example EventSink.deliver in events/sinks.py, Section and
check() in report/model.py, TeamDirectory in ownership/directory.py.
Read that protocol before you write an implementation. The
chat-dataset and chat-view seams have decorators,
register_dataset(...) and register_view_builder(...), in the same modules.
An Authenticator turns a bearer token or a request into a Principal. An
Authorizer decides what that principal may reach. Both live in
repowise.server.auth.
Principal field |
Meaning |
|---|---|
id, kind |
Who is calling. kind is free text for your own principals, such as "sso" |
repo_scope, tool_scope |
Built-in scopes. None means unrestricted |
claims |
Your own data about the caller (roles, groups, tenant). repowise never reads it. It survives the MCP HTTP token round trip, so keep it JSON-serializable |
| Authorizer method | Arguments |
|---|---|
can_access_repo(principal, identifiers, action=Action.READ) |
identifiers are the forms one repo is known by: its id, stored name or workspace alias, any of which may be None. On a workspace MCP server the id is read from each repo's index, so a scope written in repo ids works there too. An empty tuple is a server-wide route that names no repo, such as registering one: decide from action alone |
can_use_tool(principal, tool, action=Action.READ) |
action is WRITE for a tool registered with safety="mutating", or a tool the registry does not know |
action is Action.READ or Action.WRITE. A REST route's action follows its
HTTP method: GET, HEAD and OPTIONS read, anything else writes. A POST route that
only reads, such as chat, generate/estimate or preflight, declares it with
@read_route under the router decorator. Webhook deliveries are writes.
Declaring action opts in. An authorizer whose can_access_repo accepts an
action keyword (or **kwargs) is asked on every call, for every principal,
including routes that name no repo. One that does not keeps the original
contract: called without action, and asked about repos only for a principal
with a repo_scope. Both satisfy the Authorizer protocol, so an existing
plugin needs no change.
On MCP, repo="all" is refused for a principal with a repo_scope. For any
other principal, an action-aware authorizer must allow every repo it expands
to, or the call is refused.
A read-only role, with repos chosen by id:
from repowise.server.auth import Action, authorizers
class RoleAuthorizer:
def can_access_repo(self, principal, identifiers, action=Action.READ):
roles = principal.claims.get("roles", ())
if action is Action.WRITE and "writer" not in roles:
return False
allowed = principal.claims.get("repo_ids")
ids = [i for i in identifiers if i]
return allowed is None or not ids or any(i in allowed for i in ids)
def can_use_tool(self, principal, tool, action=Action.READ):
return action is Action.READ or "writer" in principal.claims.get("roles", ())
def register():
authorizers.register("roles", RoleAuthorizer())Pair it with an authenticator that returns
Principal(id=..., kind="sso", claims={"roles": [...], "repo_ids": [...]}),
then select both with REPOWISE_SEAM_AUTHENTICATOR and
REPOWISE_SEAM_AUTHORIZER.
The document-extractors seam is format-agnostic. A plugin claims lower-case
extensions that Repowise does not already process and returns Markdown from
extract(path, data). Registration requires a non-empty set of valid
single-suffix extensions (for example .pptx, not .drawio.xml), a non-empty
string version, and a callable extract method.
Supported language extensions and files whose bytes identify a supported
language (including shebang scripts) keep their normal parser and cannot be
claimed by an extractor.
Repowise keeps the original path and owns discovery, exclusions, size checks,
change detection and deletion handling. The same current plugin claim is used
when parsing working-tree files and historical Git blobs. Historical blobs are
converted with the currently installed extractor; Repowise does not retain
historical plugin implementations. The extractor's name, version and original
source bytes participate in the parse-cache fingerprint. Extracted Markdown is
cached behind the shared source reader under .repowise/document-cache/, so
ingestion, historical blob parsing and appropriate MCP reads reuse conversion.
Entries are keyed by original path, source bytes, extractor name and version.
Cache corruption or unavailable storage causes reconversion, not lost content.
The cache has no automatic eviction; removing this directory only causes
reconversion. Bump version when
extraction behaviour changes, then run repowise init --force to rebuild the
repository index; an otherwise clean repository is not scanned just because a
plugin version changed. Check repowise plugins to see which extractor owns a
claimed extension. Change-history diffs use the current plugin for the
historical blob's path and bytes; they do not replay old plugin versions or
run a full repository traversal over each historical tree. Historical blob
routing uses the changed path and that blob's bytes, applying the current
invocation's exclusions, directory boundaries and source-size rules before
conversion. It does not reconstruct historical ignore files or configuration.
Size limits for claimed documents:
| Limit | Default | Notes |
|---|---|---|
| Source file | 500 KB, the limit for any file with no parser | Set max_source_bytes (a positive int) on the extractor to change it for the extensions it claims. Registration rejects any other value |
| Source file, hard ceiling | 100 MiB | A larger max_source_bytes is clamped to it. The parent process reads the whole file and sends it to a worker |
| Extracted Markdown | 2 MB, the limit for parseable source files | Fixed |
The source limit is checked during traversal, for historical Git blobs and for
MCP reads, before the extractor runs. A document whose Markdown is over 2 MB is
not indexed and its Markdown is not cached. The run continues and reports it
with the other skipped files as
Not indexed: <path> (<size> KB, extracted Markdown over the 2,048 KB limit);
traversal stats record the reason extracted_over_max_size. When an update
finds that an indexed document has crossed either limit, the document is
removed from the index as if it were deleted.
Extraction runs in the existing spawned parse-worker pool with the
REPOWISE_PARSE_TIMEOUT_S per-file deadline (60 seconds by default; 0
disables the deadline). A timeout, worker crash, unavailable pool or extractor
exception fails the indexing run;
Repowise deliberately does not retry plugin code in-process. Workers also check
that the discovered extractor version matches the fingerprinted version. The
existing worker memory cap (REPOWISE_PARSE_WORKER_MEMORY_MB, 2048 MiB by
default; 0 disables it) is applied where the platform supports it, so these
are best-effort resource bounds rather than a hard host-memory
guarantee. Because workers use spawn, the plugin must be importable there:
install its entry point or make its module available through REPOWISE_PLUGINS
and the worker's environment. A registration made only dynamically in the
parent process is not sufficient. The deadline terminates Repowise's worker
processes, but cannot guarantee termination of a separate subprocess that a
plugin starts and detaches. Crash recovery may retry in-flight work, so plugins
must tolerate repeated calls. These limits do not bound aggregate extracted
content held by the parent process.
For a "one" seam, get() picks, first match wins:
| Order | Source | Example |
|---|---|---|
| 1 | a name the caller passes, such as --format html |
report_renderers.get("html") |
| 2 | REPOWISE_SEAM_<NAME> (seam name upper-cased, - becomes _) |
REPOWISE_SEAM_TEAM_DIRECTORY=backstage |
| 3 | the last implementation registered with default=True |
team_directories.register("backstage", impl, default=True) |
| 4 | the first implementation registered | the local default |
REPOWISE_SEAM_<NAME> is read from the process environment only, never from a
repository's .repowise/.env, so a cloned repository cannot choose code to
run. Register with default=True only when installing your package should
switch the seam on its own; otherwise register plainly and let the user opt in
with the variable.
For an "every" seam, registering is enough: repowise reads
SeamRegistry.all(), which loads plugins and returns every (name, impl) pair
in registration order. Registering a name that already exists replaces that
implementation.
from repowise.core.report.model import report_sections
for name, section in report_sections.all():
print(name, section.title)Test your implementations directly, then add one test that loads the plugin the way repowise does.
| Need | Use |
|---|---|
| A registry with nothing in it | CLIRegistry(), MCPToolRegistry(), PipelineHookRegistry() from repowise.core.registry |
| Load plugins again | repowise.core.registry.plugins.reset(), then load_plugins() |
| Load only your plugin | set REPOWISE_PLUGINS with monkeypatch.setenv and patch importlib.metadata.entry_points to return [] |
| What loaded | the PluginRecord list from load_plugins(): status, error, seams |
import importlib.metadata
from repowise.core.registry import plugins
def test_plugin_loads(monkeypatch):
monkeypatch.syspath_prepend("src") # not needed once the package is installed
monkeypatch.setenv(plugins.ENV_VAR, "repowise_hello:register")
monkeypatch.setattr(importlib.metadata, "entry_points", lambda **kw: [])
plugins.reset()
try:
[record] = plugins.load_plugins()
assert record.status == "loaded", record.error
assert "report-section:hello" in record.seams
finally:
plugins.reset()Seams are process-wide: a section or sink your test registers stays registered
for later tests in the same process. The repository's own test for the example,
tests/unit/registry/test_example_plugin.py,
shows how to restore each registry afterwards.
-
Name the distribution
repowise-<thing>(for examplerepowise-backstage) and the import packagerepowise_<thing>, so users find it and it does not collide with repowise's ownrepowisenamespace. -
Depend on
repowisewith a lower bound on the version you tested against. -
Build and upload to PyPI:
pip install build twine python -m build python -m twine upload dist/* -
In your README, give the install line, the seam names or commands you add, and any
REPOWISE_SEAM_<NAME>value a user sets.
The plugin API is experimental and can change in a minor release.
- A breaking change to a registry or seam contract bumps
PLUGIN_API_VERSIONand is named in CHANGELOG.md. - Additive changes (a new seam, a new optional field) keep the version.
- Pin
repowiseto a range you test, and checkPLUGIN_API_VERSIONinregister()if a mismatch would break silently.
When the point you need to extend has no seam, open an issue before any code. Describe:
- the contract: the protocol or callable an implementation provides,
- when it runs and whether repowise uses one implementation or every one,
- what a failure does: propagate, log and continue, or mark a result unknown,
- the local default that ships in core.
Core contributors add seams following pluggable-storage.md.