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.
- 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.
- Scaffold
quivr plugin init writes a working normalizer for text/markdown:
- 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:
--watch to replay after every source change.
- Make it yours
The manifest
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
- Parts. Each Part has a unique
key. Text Parts with the rolestitle(at most one) andbodyare indexed; other roles are stored but not searched. A search hit names its Part throughpart_key, so choose keys a reader can understand, such aspage-2orsection-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.
- Return them with
- Warnings. At most 32 warnings; a
codeinsnake_caseand 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, andTerminalError(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 testreplays every fixture.
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.
- Certify
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.
- Pin it in
QUIVR_CONFIG
Quivr invokes one pinned plugin in v0. Add a QUIVR_CONFIGplugin object to the startup
configuration of both quivr api and quivr worker:
manifestis the path to the samequivr-plugin.yamlthe plugin serves.endpointis where the plugin listens. Run it there withQUIVR_PLUGIN_HOST=127.0.0.1 QUIVR_PLUGIN_PORT=9900 python3 -m field_notes, or withquivr plugin dev --port 9900 .while you develop (it restarts on changes).configurationis validated against your configuration schema.routeslists the media types Quivr sends to the plugin. Each must be one your normalizer declares.requiredis 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:
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.
- Ingest a Blob
A Blob is uploaded through an Upload Session, then ingested by reference:
text/* nor routed is refused with
422 unverified_blob.
- 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.quarantinedchange event is emitted.
GET /v0/records/{record_id}/versions/{version_id} lists the same reason in
diagnostics, naming your plugin and the invocation:
make reset starts over.
Search. Your text Parts are searchable like any other:
GET /v0/records/{record_id}/versions/{version_id}
returns the Manifest your plugin produced, your extensions and the provenance:
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
- Python Plugin SDK: models, errors, test helpers.
- Plugin Protocol v0: the manifest, the routes and the schemas, for a plugin in another language.
plugins/pdf-text: a binary format, page Parts, bounded warnings, terminal errors and reproducible fixtures.