Skip to main content
A normalizer turns a Blob of a given media type into a Manifest: ordered Parts of text that Quivr indexes and searches. It runs in its own process and speaks the Plugin Protocol v0 over HTTP. Quivr calls it after it accepts a Blob and before it publishes the Record Version. This guide takes you from an empty directory to a searchable Record whose Version names your plugin. You write Python with the Quivr Plugin SDK and use the quivr command line; you do not need to read Quivr’s Go code. For a complete example, read the reference plugin plugins/pdf-text. It extracts PDF text into one Part per page.

  1. Get the tools

You need Python 3.12 or later and Go (the version in go.mod), plus Git. Steps 6 to 8 also need a running Quivr. The local stack, make dev, needs Linux x86_64 and Docker (see the local harness). Any installation where you can edit the startup configuration works too.
The Python Plugin SDK is installed from this repository; it is not on PyPI in v0.

  1. Scaffold

quivr plugin init writes a working normalizer for text/markdown:

  1. Develop

quivr plugin dev --fixture starts run.command from the manifest and waits for GET /v0/health. It then checks that GET /v0/discovery serves the digest of this exact quivr-plugin.yaml, sends the fixture through a file:// reference, and prints the response once Quivr’s Manifest validation accepts it:
Add --watch to replay after every source change.

  1. Make it yours

The manifest

Every edit of quivr-plugin.yaml changes its digest. Restart the plugin, and restart Quivr’s API and worker, so that both sides use the same manifest. Plugin Protocol v0 documents every field.

The function

What Quivr accepts:
  • Parts. Each Part has a unique key. Text Parts with the roles title (at most one) and body are indexed; other roles are stored but not searched. A search hit names its Part through part_key, so choose keys a reader can understand, such as page-2 or section-3. Text must be valid UTF-8 without NUL characters.
  • Blob Parts. A Blob Part may reference only the input Blob: BlobContent(blob_id=invocation.request.input.blob_id, media_type=…).
  • Extensions. Structured data goes in extension namespaces your manifest declares under extensions:. Each namespace is your plugin id, or starts with it followed by a dot, and has a JSON Schema per schema version.
    • Return them with extensions={"field-notes.outline": ExtensionEntry(schema_version="1", data={...})}, as the template does.
    • Quivr validates them against your schema and publishes them on the Version. Retrieval mappings may point at /extensions/<namespace>/data/....
    • Clients cannot write your namespaces.
    • An undeclared namespace or invalid data quarantines the Version with normalizer_invalid_output.
  • Warnings. At most 32 warnings; a code in snake_case and a message of up to 1024 characters. Log them too (invocation.logger.warning) so that operators see them.
  • Errors. Raise RetryableError(code, message) for a transient failure that Quivr should retry, and TerminalError(code, message) for input that can never be normalized, such as a damaged file. Do not return an empty Manifest.
  • Determinism. The same input must give the same output. Quivr may call the plugin again for the same Version, and quivr plugin test replays every fixture.
What Quivr indexes per Record Version (a Version beyond these limits is published but not searchable, with segmentation_limit): Stay within them in the plugin itself, for example by merging trailing Parts, and report what you merged or dropped as a warning. pdf-text shows how. Add a fixture for each case you care about: a fixtures/<name>.json describes the input file, media type and configuration (schema (contracts/plugins/v0/plugin-fixture.schema.json)). Test error cases with unit tests: every fixture in fixtures/ must succeed, while unit tests can assert a 422 reply and its code, as the template does.

  1. Certify

The Contract Runner starts your plugin and checks it over the public protocol only. It runs health and discovery, invokes every fixture, replays each idempotency key, enforces timeout_ms, and sends invalid requests. It judges the output with Quivr’s own validation. It exits 0 and prints CERTIFIED: the engine can safely invoke this plugin only when Quivr can safely call your plugin. Run it in your CI and keep the JSON report.

  1. Pin it in QUIVR_CONFIG

Quivr invokes one pinned plugin in v0. Add a plugin object to the startup configuration of both quivr api and quivr worker:
  • manifest is the path to the same quivr-plugin.yaml the plugin serves.
  • endpoint is where the plugin listens. Run it there with QUIVR_PLUGIN_HOST=127.0.0.1 QUIVR_PLUGIN_PORT=9900 python3 -m field_notes, or with quivr plugin dev --port 9900 . while you develop (it restarts on changes).
  • configuration is validated against your configuration schema.
  • routes lists the media types Quivr sends to the plugin. Each must be one your normalizer declares. required is the only mode in v0.
quivr api and quivr worker refuse to start when the pin is invalid, and list every issue: an incompatible range, a configuration that fails the schema, or an undeclared route. They never contact the plugin at startup, so a plugin that is down does not stop Quivr. With the local stack. make dev pins pdf-text by default. To pin your own plugin instead, run it on a port and point QUIVR_NORMALIZER at its directory:
The stack routes every media type your manifest declares. QUIVR_NORMALIZER_PORT changes the port (default 9900), and QUIVR_NORMALIZER_CONFIG='{"max_sections": 8}' sets the configuration. make dev prints the admin API key’s location. Run make dev again after editing the manifest.

  1. Ingest a Blob

A Blob is uploaded through an Upload Session, then ingested by reference:
A media type that is neither text/* nor routed is refused with 422 unverified_blob.

  1. Observe provenance and diagnostics

Receipt. GET /v0/ingestion-receipts/$RECEIPT shows progress. Once the plugin has answered and the Version is published, the Receipt is resolved, and availability.searchable becomes true when the text is indexed. While Quivr waits for the plugin, the Receipt stays pending, and processing.state and diagnostics explain why: plugin_unavailable means Quivr cannot reach the plugin, or the discovery digest differs from the pinned manifest. Quivr keeps retrying that case without limit and never quarantines for it. Start the plugin at endpoint, and restart it after a manifest change. Quarantine. When your plugin cannot produce acceptable output, the Version is quarantined:
  • the Receipt resolves with availability.state: "quarantined";
  • the Version is published with only its submitted input Blob Part and none of your output, so it is not searchable;
  • a record.quarantined change event is emitted.
GET /v0/records/{record_id}/versions/{version_id} lists the same reason in diagnostics, naming your plugin and the invocation:
Reprocessing quarantined Versions is not available yet. With the local stack, make reset starts over. Search. Your text Parts are searchable like any other:
Version provenance. GET /v0/records/{record_id}/versions/{version_id} returns the Manifest your plugin produced, your extensions and the provenance:
The Version keeps this output: rebuilding the search projections never calls your plugin again, and a newer plugin version applies only to new Versions. Logs. The SDK writes one JSON line per event to the plugin’s stderr. Every line written during an invocation carries the invocation_id from the Version’s provenance and the idempotency_key. Errors are logged as normalizer failed with your code. With the local stack, the pdf-text and template logs are in .scratch/<project>/normalizer-plugin.log, and the worker’s logs are in .scratch/<project>/worker.log.

Next