Writing a NovaFabric capture-hook plugin

Status: experimental. Introduced in v0.5.x with a v0.7 stability checkpoint planned (contingent on a third-party plugin being published and used in real workflows — see RFC-0001 §"Adoption / migration plan"). As of this writing (repo at v0.96.0) no ADR or release note records that stability checkpoint having been formally revisited or declared met — the in-tree reference implementation (examples/plugin-hook-reference/) is NovaFabric's own example, not independent third-party evidence. Treat the contract as still experimental and pin your plugin's novafabric dependency accordingly until a stability decision is recorded.

Working reference: examples/plugin-hook-reference/ is a complete, installable Python package that demonstrates everything below end-to-end. Clone it as a starting template:

cp -r examples/plugin-hook-reference my-novafabric-plugin
uv pip install -e my-novafabric-plugin/
nova capture python -m novafabric_example_plugin.demo
# → captured record carries:
#   "io.novafabric.capture_method": "plugin"
#   "io.novafabric.plugin_name": "acme-ai-reference"

The doc below explains each piece. The reference proves they fit together.


Why plugins exist

NovaFabric's capture surface ships with hooks for a small set of SDKs and protocols (OpenAI, Anthropic, MCP, plus the wire-level layer over httpx and requests). Per RFC-0001 Option C, per-SDK hooks are no longer the project's primary growth axis — wire-level capture and OTel-native ingest carry that load. But some SDKs cannot be reached from those layers (framework-specific control flow, in-process state graphs that never hit the wire).

Plugins are the long tail's path. You publish a Python package that exposes a hook; NovaFabric discovers it at capture time and installs it alongside the built-ins. NovaFabric does not maintain the plugin, ship it, or vouch for it.


Minimum viable plugin

A plugin is two files: a hook class and a pyproject.toml entry-point declaration.

The hook class

# my_plugin/hook.py
from typing import Any

from novafabric.capture.hooks._plugin import HookPluginInfo


class AcmeHook:
    """Capture hook for the (fictional) Acme agent SDK."""

    def __init__(self, writer: Any, parent_span_id: str) -> None:
        self._writer = writer
        self._parent_span_id = parent_span_id
        self._original = None

    def install(self) -> None:
        try:
            import acme_sdk
            self._original = acme_sdk.Client.run
            hook_self = self

            def patched_run(client_self, *args, **kwargs):
                # ... inspect arguments, call original, record to writer ...
                return hook_self._original(client_self, *args, **kwargs)

            acme_sdk.Client.run = patched_run
        except ImportError:
            # SDK absent at runtime → silent no-op. Identical pattern to
            # the built-in hooks (capture/hooks/_openai.py et al.).
            pass

    def uninstall(self) -> None:
        if self._original is None:
            return
        try:
            import acme_sdk
            acme_sdk.Client.run = self._original
        finally:
            self._original = None

    @classmethod
    def info(cls) -> HookPluginInfo:
        # Optional. Used by NovaFabric for log/diagnostic output.
        return HookPluginInfo(
            name="novafabric-acme",
            version="0.1.0",
            capabilities=("model-calls",),
        )

The entry-point declaration

# pyproject.toml of the plugin's package
[project]
name = "novafabric-acme"
version = "0.1.0"

[project.entry-points."novafabric.hooks"]
acme = "my_plugin.hook:AcmeHook"

After pip install novafabric-acme, NovaFabric's install_all discovers acme in the novafabric.hooks entry-point group and installs it alongside the built-ins.


The contract

The full contract is HookProtocol in _plugin.py. Short form:

Method Required Purpose
__init__(writer, parent_span_id) yes Receive the active capsule writer and parent OTel span id. Plugin code stores them.
install() yes Patch the target SDK or library. Must not raise — failures should be caught internally and leave the runtime in a clean state.
uninstall() yes Reverse install(). Must be idempotent and safe to call when install() was never called or failed.
info() no Return a HookPluginInfo. NovaFabric calls this on the class (use @classmethod or @staticmethod). Used only for logs. Plugins without it still work.

The writer argument is a novafabric.capture.capsule.CapsuleWriter. The two methods plugins should use:

self._writer.append_model_call({...})  # for LLM calls
self._writer.append_tool_call({...})   # for tool / RPC calls

The record schemas are documented in design/spec/tool-call-v0.md and design/spec/model-call-v0.md. The capsule writer is thread-safe; multiple hooks (built-in + plugins) writing concurrently is supported.

Public surface, in writing. The two constructor arguments (writer, parent_span_id) and the three lifecycle methods are the public surface. Anything starting with _ on the writer or hook objects is internal and may change without notice. Coupling to internals will break.


Lifecycle

nova capture <cmd>
   └─ orchestrator opens capsule writer
      └─ install_all() called in subprocess
         ├─ Built-in hooks (OpenAI, Anthropic, MCP, requests, httpx) install
         └─ Plugins discovered + installed (one isolated try/except per plugin)
                                 ↓
            agent runs, hooks record into capsule
                                 ↓
                     uninstall_all() at exit

A plugin that raises in __init__ or install() is logged at WARNING and skipped. Built-in capture continues. The user sees a one-line message; the run proceeds.


Versioning posture

Where this stands as of v0.96.0: the repo is well past v0.7 and no ADR or release note in-tree records that checkpoint having been formally revisited — this doc's own bounded-experimental-period framing was written assuming the checkpoint would be actively re-decided at v0.7, and that re-decision does not appear to have happened. Until a stability decision is recorded, continue to treat the contract as experimental rather than assume it lapsed into stable by default. This experimental period was meant to be bounded on purpose — silently extending it indefinitely would push plugin authors away — so an unresolved checkpoint like this one is itself a signal to revisit the design via a successor RFC, not to guess at its status.


Testing your plugin

The pattern NovaFabric uses internally (see tests/test_capture_plugin_contract.py) injects fake entry points via unittest.mock.patch against importlib.metadata.entry_points. You don't need a real pip install to exercise the discovery flow.

For unit-testing the hook itself, follow the in-tree examples (tests/test_capture_hooks.py): construct the hook with a CapsuleWriter pointed at a tmp_path, call your wrapped method, then read back tool-calls.jsonl or model-calls.jsonl and assert against the records.


What plugins do not get

If any of these become real adoption blockers, the relevant constraint will be revisited in a successor RFC. For now, the plugin path is minimum viable.


Where to look