Skip to main content
The authoritative, language-neutral contract between the Quivr engine and an external plugin. It covers Plugin API version 0.8.0: 0.2.0 added the subscription Contribution to Plugin API 0.1.0, 0.3.0 added connector, 0.3.1 the instance scope to connector fetch requests, the declared checkpoint bound and _ in plugin ids and extension namespaces, 0.4.0 connector attachments, 0.5.0 the connector push mode (receive), and 0.6.0 the ingestion Contribution, 0.7.0 the retrieval Contribution, and 0.8.0 segment-only segment_and_embed requests (spaces: []). JSON Schemas in this directory are the source of truth; SDKs and the Contract Runner implement them, not the other way round. Design context: ADR 0001 (docs/adr/0001-plugin-cli-and-contract-runner-in-quivr-binary.md), ADR 0002 (docs/adr/0002-record-version-identity-from-submitted-input.md) and the glossary in CONTEXT.md. The Manifest, Part, Extensions, Relation, SourceIdentity and Provenance shapes are not defined here. They come from contracts/shared/v0/manifest.schema.json, the single source also used by the public HTTP contract, so a normalizer returns exactly the Manifest a client could submit as kind: "manifest". go test ./contracts/ (part of make test) fails if an openapi.yaml component for one of those shapes is anything but an alias to the shared file, or if a plugin schema declares a $defs entry with a shared name; the bundler used by make contracts also rejects a malformed alias. Reviewers still keep equivalent copies under other names out of both contracts.

Contributions

Plugin API 0.7 accepts five Contributions, and a manifest declares at least one: The names enricher, validator, projector and retriever are reserved (declare retrieval for search). A manifest that declares them is rejected (reserved_contribution).

Plugin API versions

A minor Plugin API version only adds to the previous one. This engine implements 0.8.0 and still serves every 0.1 to 0.7 plugin unchanged: a manifest is compatible when its plugin_api range admits any supported version (0.1.0, 0.2.0, 0.3.0, 0.3.1, 0.4.0, 0.5.0, 0.6.0, 0.7.0 or 0.8.0), and the engine speaks the highest one the range admits. A manifest field introduced by a later minor version needs a range that admits it: contributions.connector.attachments (0.4) with plugin_api: ">=0.3.0 <0.4.0" is incompatible_plugin_api at that field, and so is a kind’s push mode (0.5) at its modes. A patch version only adds optional fields; a plugin that validates requests strictly accepts them once it is built with an SDK of that version. quivr plugin inspect reports that negotiated version. Discovery must serve a supported version inside the declared range that is at least the version each declared Contribution needs; SDKs serve the negotiated one. A Contribution needs a range that admits the version that introduced it: subscription with plugin_api: ">=0.1.0 <0.2.0" is incompatible_plugin_api at /contributions/subscription. Discovery lists Contributions as a set, in any order.

HTTP routes

The plugin serves JSON over HTTP. Paths are versioned by the Plugin API major version.
  • Errors. Every non-2xx response carries the error envelope {code, message, retryable}. retryable: true asks the engine to retry within the declared retry intent. retryable: false is terminal.
  • Unavailability. A connection failure, a timeout, or a 5xx without a valid envelope counts as plugin unavailability, not a plugin decision.
  • Manifest digest. manifest_digest is sha256: followed by the lowercase hex SHA-256 of the exact bytes of the quivr-plugin.yaml the plugin was built from. quivr plugin inspect prints the same value.
  • Future Contributions use /v0/contributions/<name>, with one sub-route per operation when a Contribution has several (connector/fetch).

Manifest (quivr-plugin.yaml)

Version ranges

A range is one or more comparators separated by whitespace, and every comparator must hold. A comparator is an optional operator (>=, >, <=, <, =) directly followed by MAJOR.MINOR.PATCH. No operator means =. Shorthands (^, ~, x), || and pre-release versions inside a range are not part of the grammar. Versions compare by SemVer 2.0.0 precedence, so 0.2.0-rc.1 satisfies <0.2.0. A range that no release version satisfies, such as >=0.3.0 <0.2.0, is invalid. fixtures/ranges.json is normative for every implementation. This engine implements Plugin API 0.3.1 (and serves 0.1.0, 0.2.0 and 0.3.0) and reports engine version 0.1.0. Release builds may override the engine version. quivr plugin inspect --json reports both, and the negotiated Plugin API version under compatibility.plugin_api.version.

Invocation context

The normalizer request carries:
  • the invocation id and idempotency key;
  • the Organization, Corpus, Record and Record Version ids;
  • the source namespace and Record Key;
  • the input Blob id, media type, size and SHA-256;
  • the submitted extensions and provenance;
  • the validated plugin configuration;
  • a reference to the input Blob: a short-lived signed GET URL or, in local development only, a file:// URL.
Bodies are never inline. The idempotency key is opaque to plugins and stable across retries of the same logical invocation. The same key must produce the same logical output.

Validation rules

Checked today. quivr plugin inspect checks a manifest against:
  • the manifest schema, including unknown fields;
  • well-formed and satisfiable ranges;
  • compatibility with this engine and a supported Plugin API version, and with the version each declared Contribution needs;
  • reserved Contributions and reserved fields;
  • subscription expression and configuration schemas that compile as JSON Schema 2020-12 (invalid_expression_schema, invalid_config_schema);
  • connector kind config and credential schemas that compile (invalid_config_schema, invalid_credential_schema);
  • extension namespaces equal to the plugin id or prefixed by <id>.;
  • configuration and extension schemas that compile as JSON Schema 2020-12;
  • duplicate secret names.
The normalizer response fixtures are checked against the response schema plus the engine’s structural Manifest rules: unique Part keys, known and acyclic parents, valid text, complete Relation targets, and the Part count and structure bounds. Protocol rules checked at certification time and enforced at ingestion. These rules are part of the v0 contract. quivr plugin test (the Contract Runner, below) checks them on every normalizer response with the validation in internal/plugins (CheckNormalizerOutput), and the engine applies the same function to every invocation of a pinned normalizer before anything is recorded.
  1. Blob Parts may reference only the input Blob. A normalizer response must not introduce other Blobs. A Blob Part must name the input Blob id with its media type (foreign_blob, unverified_blob). The public Blob Part carries no checksum. The reference is resolved to the verified input Blob, and its SHA-256 must equal the request’s input.sha256. The runner re-hashes the file it served. Checked by the Contract Runner; enforced by the engine (THE-683).
  2. Response size cap. A response larger than the declared max_response_bytes (default 4 MiB) is invalid output (response_too_large). The engine caps the declared value at 16 MiB. Checked by the Contract Runner; enforced by the engine (THE-683).
  3. Namespace ownership. Response extensions, top-level and on Parts, may use only namespaces and schema versions the plugin declares, with data valid against the declared schema (undeclared_namespace, undeclared_schema_version, invalid_extension). Clients may not write plugin-owned namespaces. The output side is checked by the Contract Runner. The engine enforces both sides (THE-684): it registers the pinned plugin’s namespaces at startup (refusing a namespace not prefixed by the plugin id, foreign_namespace, or clashing with a built-in one, namespace_conflict), publishes valid output extensions on the Version, fails invalid ones as normalizer_invalid_output, and rejects client writes with 422 extension_namespace_owned. Retrieval mappings may point at /extensions/{plugin namespace}/....
A response with more Parts than the declared max_parts is also invalid (too_many_parts).

Subscription Contribution

An alert rule (since Plugin API 0.2). The core sends one Record Version and a batch of distinct evaluations; the plugin answers one decision per evaluation. A plugin pinned at startup (plugins in the core configuration, beside the normalizers) is installed as the evaluator <id>@<version>, and Subscriptions pin it by plugin_id and version. When the core asks. When a Record Version becomes searchable (record.retrieval_ready), and again once its embeddings are attached (record.enrichment_available) for every Subscription that answered not_ready. A rule that needs enrichment answers not_ready while enriched is false. Nothing is evaluated at acceptance time, before the Version is searchable. The core batches every due Subscription of one Record Version pinned to the plugin, across Subscriptions and owners, into as few requests as max_batch_size allows. A Subscription Version that decided a Record Version (match or no_match) is not asked about it again, even when a later trigger arrives while it is being decided, so a rule that calls a paid backend pays once per Record Version. One worker at a time claims the due evaluations of a Record Version, so they are not split between concurrent requests. Previews. The API also asks when a client previews a proposed Subscription (POST /v0/subscription-previews): one request per recent Record Version, each with one evaluation whose subscriptions holds a single synthetic reference whose Subscription and Subscription Version ids are preview (its Saved Query ids too, when the Saved Query is inline). The decision is returned to the client and never stored. So the API process must reach the plugin’s endpoint too, and a plugin that calls a paid backend pays for each Record Version a preview judges (at most 50 per preview). Request (subscription-request.schema.json):
  • invocation_id, idempotency_key, organization_id and the validated plugin (installer) configuration, as for every Contribution;
  • record: the Corpus, Record and Record Version ids, enriched (embedding coverage in the active generation) and the canonical text parts (key, role, text; at most 256, unique keys, Blob Parts are not sent);
  • the record’s metadata, which rules may test besides the text. They are optional in the schema and the core always sends them (the extensions when the Version has some):
    • source: the Source namespace, record_key and, when the producer sent one, the Source position of the accepted revision;
    • accepted_at: when Quivr accepted the revision (RFC 3339, UTC);
    • provenance: origin (connector for a revision a Connector Instance acquired, client otherwise; a client cannot claim connector), the producer and producer_version, the connector (instance_id, kind) and the external normalization (plugin_id, plugin_version, contribution, fallback);
    • extensions: the Version’s structured metadata by namespace, such as an author, a section or categories when the source provides them.
    These fields were added to Plugin API 0.2 before the core first called subscription plugins. A plugin that validates requests against an earlier copy of the 0.2 schemas (Python SDK 0.2.0) refuses them: rebuild it with the current SDK (0.2.1 or later);
  • evaluations: 1 to max_batch_size items, each with an id unique in the request, the pinned Saved Query Version expression, the pinned Subscription evaluator configuration, and the subscriptions (Subscription, Subscription Version, Saved Query and Saved Query Version ids, and the Subscription owner when it has one) it stands for.
The core deduplicates: Subscriptions that share the same expression and configuration become one evaluation. The ids are informational (logs, tracing). A decision must depend only on the record, the expression and the configurations, never on the ids, the position in the batch or the other evaluations, because the core batches freely. The same idempotency key must yield the same decisions and evidence. Requests stay within 16 MiB: the core splits a batch whose request would be larger. The call deadline is timeout_ms, capped by the core at 30 seconds. Per-Part vector and per-evaluation query_vector are reserved for local-vector matching; Plugin API 0.2 never sends them and the schemas reject them. Response (subscription-response.schema.json): decisions, exactly one per requested evaluation id. A rule that cannot decide at all (a backend down) answers with the error envelope. The core keeps every evaluation of the batch pending and retries it with backoff (from one second up to five minutes) until it is decided; a plugin that is down, times out, serves another manifest or answers outside these rules is treated the same way. Plugin unavailability never becomes a negative decision: alerts are delayed, never lost or skipped. An error envelope applies to the whole request. A retryable: true envelope (a backend down) retries the batch as one; after a terminal envelope or an invalid answer the core halves the batch until the evaluation that fails is alone, so the other evaluations are decided. A request the schemas reject is refused with retryable: false. Evidence bounds. They mirror the bounds the monitoring engine applies to Match evidence (internal/monitoring), and CheckSubscriptionOutput in internal/plugins judges every answer: Expression and configuration schemas. The core validates Saved Query expressions against expression_schema and Subscription configurations against configuration_schema when it pins them, so a rule receives only values its schemas accept (ValidateSubscriptionItem, codes invalid_expression and invalid_subscription_configuration). Creating a Subscription or a Subscription Version whose expression or configuration the schemas refuse is 422 with that code, the request member at fault in field and the first schema issue in message. To offer several kinds of alert, discriminate them with a oneOf over a constant kind property; quivr plugin inspect lists the kinds. An installation may accept only some of them: the pin’s optional kinds in the core startup configuration (for example ["keywords"], when the plugin has no credentials for the backend of another kind) must name declared kinds, and the core refuses a Saved Query of any other kind with invalid_expression.

Connector Contribution

A source collector (since Plugin API 0.3). The core keeps everything durable: Connector Instances, schedules and leases, Acquisition Checkpoints, Deposited Credentials and Connector Health. The plugin only fetches, and is stateless between invocations. Pinned connector plugins provide Connector Instance kinds (Run a connector plugin). fetch (connector-fetch-request.schema.json) carries invocation_id, organization_id, the installer configuration, the Connector Instance (connector: instance_id, kind, config, and since 0.3.1 the corpus_id and source_namespace the instance writes to, so a plugin can bind Relation targets to full Source Identities), the decrypted credential (null for a kind without one), the opaque checkpoint the plugin returned last (null on a first run), now, page_in_run (0, then counting up while the plugin answers more: true) and reads_today. The response carries:
  • items, at most max_items. Each has a record_key (unique in the page), an optional revision and source_position, and exactly one of content (the shared TextContent or ManifestContent) and withdraw: true (a Tombstone, such as a post deleted at the source). Extensions, on the item and its Parts, use only namespaces the plugin declares. A Manifest has no Blob Parts (blob_part_not_allowed): binary Parts are attachments (key, parent_key, role, media_type, optional size_bytes, sha256 and extensions, and an opaque ref), which need Manifest content and a manifest that declares attachments (attachments_unsupported otherwise). The core asks for their bytes later, only for an item it does not already have (Connector attachments);
  • checkpoint (required, any JSON value of at most max_checkpoint_bytes serialized as compact JSON, 64 KiB unless the manifest declares more), which resumes after this page. The core persists it and advances it only after the page’s items are accepted, so a migrated plugin keeps reading the checkpoints it wrote;
  • more, which asks for another page in the same run and needs a checkpoint that moved;
  • optional reads (source resources read, for usage counters), diagnostics (an object of at most 16 KiB, shown as Connector Health diagnostics) and notice (a code that ends a run that otherwise completed, such as a spend cap);
  • not_due: true when the source asked not to be polled yet, with no items, more: false and the request’s checkpoint unchanged; the core skips the run.
There is no idempotency key: a fetch reads a changing source, and the core deduplicates items by Record Key and revision. CheckConnectorOutput in internal/plugins judges every answer: the rules above, invalid_item, duplicate_record_key, too_many_items, checkpoint_too_large, diagnostics_too_large, invalid_not_due, the engine’s Manifest rules with the attachments as Parts, declared namespaces and response_too_large.

Connector attachments

Since Plugin API 0.4, a manifest that declares contributions.connector.attachments (max_bytes, at most and by default 25 MiB, and timeout_ms, at most and by default 120 s, per call) lets items carry attachments. The plugin never writes to storage on its own. For each attachment of an item the core has not accepted yet:
  1. describe_attachment (connector-describe-attachment-request.schema.json) carries the usual envelope, the item (record_key, revision, current extensions) and the attachment descriptor. The plugin reads the bytes and answers {"size_bytes", "sha256"} (lowercase hex), or {"skip": "<code>"} with optional item_extensions that replace the item’s, for example to record the skipped attachment. A size above max_bytes is attachment_too_large: answer {"skip": "too_large"} instead. A descriptor that already carries an exact size_bytes and sha256 is not described.
  2. The core reuses a verified Blob with the same identity, or issues a grant: one presigned PUT (url, method, headers, size_bytes, sha256, media_type, expires_at) that storage accepts only with exactly those bytes. A grant is a capability: never log or return it (credential_leak).
  3. upload_attachment (connector-upload-attachment-request.schema.json) carries the same fields and the grant. The plugin sends the PUT with exactly the grant’s headers and answers {"status": "uploaded"}. Bytes that changed at the source since they were described are a source error attachment_changed; the core describes the attachment again once, then rejects only that item.
  4. The core reads the stored bytes back and checks their size and SHA-256 before the Blob becomes a Blob Part of the item. Bytes that fail the check are never used, the item is not accepted and the checkpoint does not move.
CheckAttachmentAnswer and CheckUploadAnswer in internal/plugins judge the answers for the engine and the Contract Runner. check_credential (connector-check-credential-request.schema.json) carries the same Instance, credential and now, and answers {"status": "ok"} with an optional expires_at, which drives the credential_expiring health state. A refused credential is an access error. Errors. A connector error envelope carries error_class: A connector error without error_class, or whose retryable disagrees with it, is wrong_error_class. Refusing an invalid request stays a terminal envelope, with or without a class. Credentials reach the plugin only in the invocation that needs them. A plugin never logs, returns or stores them; the Go SDK redacts them from its logs and error messages, and the Contract Runner checks that none appears in a response or in the plugin’s output. Connector fixtures (connector-fixture.schema.json, a file with a top-level connector property) hold the kind, config, credential, an optional plugin configuration, a starting checkpoint (default null), now (default 2026-01-01T00:00:00Z), max_pages (default 10) and what to expect: the Record Keys (and more) of each page, an error (error_class, optional code) or the check_credential answer. The Instance is dev-connector-<digest>, invocations dev-invocation-<digest>-<page>, from the first 16 hex digits of the SHA-256 of the fixture bytes; fetch requests carry corpus_id dev-corpus and source_namespace dev-namespace, and for a push kind the webhook_url https://quivr.invalid/v0/connector-webhooks/<instance>. fixtures/connectors/feed.json is a normative example. A push kind’s fixture lists receive cases (since 0.5): a request (method, query, headers, and a text body or a body_base64) and what to expect (verdict, status, record_keys, body_contains, or an error), relayed with the fixture’s config, credential and checkpoint; fixtures/connectors/push.json is a normative example.

Connector push

Since Plugin API 0.5, a kind with modes: [pull, push] also receives what the source sends. The core owns one public route per Connector Instance, /v0/connector-webhooks/<instance> under the deployment’s public_url, and sends that address to the plugin as connector.webhook_url in fetch requests, so the plugin registers it with the source and is never exposed itself. For each GET or POST to the route, with a body of at most 1 MiB, the core calls receive (connector-receive-request.schema.json) with the Instance (and its corpus_id and source_namespace), the credential, the current checkpoint (read-only), now, reads_today and the relayed request: method, raw query, headers by lowercase name (at most 64; no hop-by-hop header or Cookie) and body_base64, the exact bytes a signature covers. The deadline is timeout_ms, capped at 8 seconds. The plugin verifies the request with the credential and answers (connector-receive-response.schema.json) a verdict and the response the core returns to the source (status, optional content_type and a text body of at most 64 KiB):
  • accepted with a 2xx status: the request is authentic. Its items (the fetch item shape, without attachments) are ingested before the source is answered, through the same path as a pull run’s, so an item both paths return converges on the same Receipt. A challenge, such as a CRC check, is accepted with no items and the answer in the body. reads counts toward the usage counters.
  • refused with a 4xx status and no items: not authentic or not for this instance. The core answers the source and changes nothing.
CheckReceiveOutput judges every answer (invalid_verdict, the item rules, declared namespaces). An answer carries no checkpoint: pull runs own it. When the plugin is unavailable, or answers a transient error, the source gets a 503 with Retry-After; an access or source error is a 500. Those failures show in Connector Health (health.push), never a refused request. A fetch answer may report the kind’s push channel in push: state active, pending (with an optional code saying why) or failed (with error_class and code). An access failure is the access_error health state while pull carries on. With active, poll_interval_seconds relaxes pull to a safety net while nothing goes wrong. A pull run that, with push active since before it started, still creates new Record Versions reports missed_deliveries and returns pull to the instance’s interval, so a plugin holds back items the source may still be delivering. Only a plugin with a push kind reports a push status (invalid_push_status).

Ingestion Contribution

Segmentation and embedding (since Plugin API 0.6). The plugin cuts one Record Version’s text into segments and embeds each segment in the vector spaces it owns; it also encodes queries into those spaces, so a query vector always comes from the model that made the document vectors. The core keeps everything else: the vector space registry, Weaviate and its named vectors, projection generations and rebuilds, authorization and withdrawal. Write one with the Go SDK and pin it as described in Write an ingestion plugin. Spaces. contributions.ingestion.spaces declares each space by id: the plugin id or an id starting with <plugin id>. (foreign_space otherwise), so a space has exactly one owner. A space’s identity is its id and version together (<id>@<version>, as search hits and the registry report it); bump the version whenever the vectors change. model, dimensions, metric (cosine, dot or l2) and the modalities it indexes and accepts as query_modalities (only text in 0.6) describe it. At startup the core registers the enabled spaces and refuses one registered under another owner (space_owner_conflict) or with another model, dimensions or metric under the same version (space_changed). segment_and_embed (ingestion-segment-and-embed-request.schema.json) carries the usual envelope and idempotency key, the Record Version identity, an optional language hint, the Version’s text parts (key, role, text, in Manifest order) and the spaces to embed, the ones the deployment enables. The answer (ingestion-segment-and-embed-response.schema.json) lists segments in reading order: part_key, start and end in Unicode code points, vectors with exactly one vector per requested space, and optionally lexical_text (text the core indexes in a separate keyword field, while excerpts keep the source text) and provenance (stored, never interpreted). The same idempotency key must yield the same answer: rebuilds reuse stored segments and vectors, and a different answer for the same Version is refused. Since 0.8, spaces may be empty: the answer then carries the segments with vectors: {}. The core asks a plugin whose plugin_api range admits 0.8 for the segments alone when a Version arrives, so it is searchable by keyword whatever the embedding backend’s state, and for the vectors at enrichment; the segments, lexical text and provenance must be the same both times, or the Version is blocked (derivation_conflict). A plugin limited to 0.6 is asked once, with the spaces. embed_query (ingestion-embed-query-request.schema.json) carries the space and a query (modality: text, text) and answers {"vector"} within query_timeout_ms, which a search waits for. Errors. Unavailability or a retryable: true envelope delays the Version, retried with backoff; a terminal envelope, or an answer the checks below refuse, blocks it as ingestion_refused. For embed_query, a terminal envelope refuses the search (422) and anything else makes it unavailable (503). A query longer than the space accepts is refused with code query_too_long and a message naming the limit, such as query exceeds 256 tokens: the search fails with 422 query_too_long and that message (at most 256 code points). Any other terminal code is 422 unsupported_search. CheckSegmentAndEmbedOutput and CheckEmbedQueryOutput in internal/plugins judge every answer for the engine and the Contract Runner: Deployment. A pin enables spaces with spaces: {"<id>": "served" | "evaluation"} (absent: the only declared space, served); exactly one is served, and a deployment pins one ingestion plugin (ingestion_conflict). A projection generation carries the spaces enabled when it was built, served first, each as a named vector. A Corpus goes through the plugin once its routed generation is served by one of the plugin’s spaces; a Corpus built before keeps its path until it is rebuilt, and the rebuild calls the plugin only for Versions without stored vectors in the new spaces. GET /v0/corpora/{corpus_id}/vector-spaces lists a Corpus’s spaces with their owner, role and coverage. Ingestion fixtures (ingestion-fixture.schema.json, a file with a top-level ingestion property) hold the parts, an optional language, configuration, spaces (default: every declared space) and queries (default: the first 200 code points of the first non-empty Part), and what to expect: the exact segments and whether every segment carries lexical_text. Ids derive from the first 16 hex digits of the SHA-256 of the fixture bytes (dev-record-…, dev-version-…), with Corpus dev-corpus, Organization dev-organization and idempotency key dev:<sha256>. fixtures/ingestion/article.json is the normative fixture every ingestion plugin is certified with.

Retrieval Contribution

Search (since Plugin API 0.7). The plugin answers a search in rounds; each round it either asks the core for candidates or returns the final ranking. It never queries the index itself and never sees a model: it names a vector space and the core encodes the query with that space’s owner. The core keeps the index, authorization, withdrawal fences and generation routing, and applies them before any candidate reaches the plugin. Write one with the Go SDK and pin it as described in Write a retrieval plugin. Profiles. contributions.retrieval.profiles declares the search profiles the plugin answers, by name (^[a-z][a-z0-9_]{0,31}$), default required. Each has a max_latency_ms (50 to 10000), the deadline of a whole search under it, and a max_cost_cents, the most one search may report spending in usage. limits bound a search: max_rounds (1 to 3, default 3), max_requests per round (1 to 8, default 4), max_candidates, the largest k (1 to 100, default 50), and max_response_bytes (default 1 MiB, cap 4 MiB). search (retrieval-search-request.schema.json) carries the envelope, the profile, the round (1 first), the query (text and the client’s mode, a hint), the limit, the scope (corpus_ids and, when the client filters, source_namespaces), the spaces every requested Corpus’s routed generation carries (id, owner, model, dimensions, metric, modalities, role, coverage), and served: every request of earlier rounds with the candidates the core served for it. A candidate is an authorized, current segment: segment_id, record_id, version_id, part_key, its text, start and end in code points, and the index score (higher is better). The answer (retrieval-search-response.schema.json) is exactly one of:
  • requests: candidate requests, each bm25 (field source or lexical, query_text), near_vector (space, and query_text or a vector) or hybrid (space, query_text, field, alpha, fusion relative_score or ranked), with k, an optional filter (source_namespaces, inside the scope) and group_by: record;
  • ranking: hits, best first, each a served segment_id with the plugin’s score and an optional explanation the API returns with the hit.
Either may carry usage (paid_calls, cost_cents). The same request must yield the same answer. Errors. A terminal envelope refuses the search (422 unsupported_search); unavailability or a retryable envelope makes it unavailable (503); an answer the checks below refuse fails it with 502 retrieval_plugin_invalid; a search that outruns the profile’s max_latency_ms fails with 504 search_deadline_exceeded. CheckSearchOutput and RetrievalSession in internal/plugins judge every answer for the engine and the Contract Runner: Deployment. A pin needs no routes; a deployment pins one retrieval plugin (retrieval_conflict), which then answers every search. GET /v0/search/profiles lists its profiles. Without one, the built-in path answers the default profile. Retrieval fixtures (retrieval-fixture.schema.json, a file with a top-level retrieval property) hold a query, mode, limit, configuration, the profiles to certify (default: all), the spaces offered (default: fixture.served@1, 8 dimensions) and a catalogue of candidates. The runner serves each request from the catalogue: the fixture’s order for the primitive, or else the segments sharing words with the query text (every segment for near_vector), most shared words first, then filters, group_by and k. expect.top names the ranking’s first hits. fixtures/retrieval/newsroom.json is the normative fixture every retrieval plugin is certified with.

Normative fixtures

fixtures/index.json lists every fixture with its schema and two outcomes. schema_valid is the result of JSON Schema validation alone. valid is the result after the semantic rules. For manifests, normalizer responses and subscription responses, errors lists the exact set of error codes. A subscription response is judged against the request its request field names. fixtures/ranges.json binds range parsing and matching. Go tests (go test ./internal/plugins/...) run all fixtures through the engine’s validation. checks/validate.py, run by make contracts, checks the schema outcomes with an independent JSON Schema implementation.

Local development

These conventions are part of the v0 tooling contract. They bind SDKs in every language and the Contract Runner, but not the engine. Run convention. quivr plugin dev starts the manifest’s run.command (argv, no shell) in the plugin directory, in its own process group, with: The plugin must serve the routes above on that address. dev stops it with SIGTERM, then SIGKILL after five seconds. Invocation fixtures (plugin-fixture.schema.json) name an input file, relative to the fixture, with its media type and optional configuration, source, extensions and provenance. A tool turns one into a normalizer request:
  • input.reference is {"kind": "file", "url": <absolute file:// URL>}, and size_bytes and sha256 are computed from the file;
  • the ids are development values derived from the first 16 hex digits of the input SHA-256: dev-invocation-…, dev-record-…, dev-version-…, dev-blob-…, plus organization_id dev-organization;
  • idempotency_key is dev:<input sha256>;
  • source defaults to {"corpus_id": "dev-corpus", "namespace": "dev", "record_key": <input.path>}, and corpus_id follows source.corpus_id;
  • configuration defaults to {} and is validated against the manifest configuration schema.
fixtures/invocations/markdown.json is a normative example. Subscription fixtures (subscription-fixture.schema.json) hold the record’s text Parts (and enriched), its optional metadata (source, accepted_at, provenance, extensions), optional plugin configuration, and the evaluations, each with an expression, an optional configuration and an optional expected decision. A file is a subscription fixture when it has a top-level evaluations property. A tool turns one into requests:
  • evaluations are numbered e1, e2, … in order; evaluation n stands for Subscription dev-subscription-n (Version dev-subscription-version-n) of Saved Query dev-saved-query-n (Version dev-saved-query-version-n);
  • the Record Version ids derive from the first 16 hex digits of the SHA-256 of the fixture bytes: dev-record-…, dev-version-…, Corpus dev-corpus, Organization dev-organization;
  • metadata the fixture omits defaults to source dev-namespace / dev-record-…, provenance {"origin": "client"} and accepted_at 2026-01-01T00:00:00Z;
  • evaluations are split into batches of max_batch_size; batch i has invocation id dev-invocation-…-i and idempotency key dev:<sha256>:i;
  • the configuration, every expression and every evaluation configuration are validated against the manifest schemas, and Part keys must be unique.
fixtures/subscriptions/strike.json and fixtures/subscriptions/metadata.json are normative examples. quivr plugin dev does not replay connector fixtures; quivr plugin test runs them.

Try it

Exit codes: 0 valid, 1 invalid or incompatible, 2 usage error. Scaffold and run a Python normalizer with the SDK in sdks/python:
quivr plugin init alerts --kind subscription scaffolds an alert rule instead: a case-insensitive phrase rule with a kind-discriminated expression_schema, a subscription fixture with expected decisions, and tests. quivr plugin dev [--fixture <file>] [--watch] [--port <n>] [--startup-timeout <duration>] [<plugin-dir>] runs these steps:
  1. inspects the manifest;
  2. starts run.command, waits for GET /v0/health and checks that GET /v0/discovery matches the manifest: digest, id, version, Plugin API range and Contributions;
  3. with --fixture, sends the fixture’s request, validates the answer with the same output checks as the Contract Runner (engine Manifest rules, response size, Blob Parts, declared namespaces), and prints the response on stdout. With a subscription fixture, it sends every batch, validates each answer with CheckSubscriptionOutput and the expected decisions, and prints the decisions of all batches.
It exits 0 when every check passes and 1 otherwise. Without --fixture, or with --watch, it keeps running and restarts the plugin when a file in the plugin directory changes; hidden directories, __pycache__, virtual environments and build outputs are ignored.

Contract Runner

quivr plugin test [--endpoint <url>] [--report <file>] [--fixture <file>]... [--startup-timeout <duration>] [<plugin-dir>] certifies that the engine can safely invoke every Contribution a plugin declares. It talks only the public protocol, so it applies to a plugin in any language, and it judges answers with the engine’s own validation, never a copy. It starts run.command like dev. With --endpoint, it targets a running plugin instead and still reads quivr-plugin.yaml from <plugin-dir>, because discovery carries only the manifest digest. These normalizer checks carry "contribution": "normalizer" in the JSON report. When the manifest declares subscription, the runner adds checks with "contribution": "subscription": When the manifest declares connector, it adds checks with "contribution": "connector", from the plugin’s own connector fixtures: When the manifest declares ingestion, it adds checks with "contribution": "ingestion": When the manifest declares retrieval, it adds checks with "contribution": "retrieval": The human report goes to stdout; the plugin’s own output goes to stderr. --report <file> writes the JSON report described by reports/contract-report.schema.json. Exit codes: 0 certified, 1 not certified, 2 usage error. The deliberately broken plugins in tests/plugin-contract/ show one failure per rule, for every Contribution; the connector rules run one fake plugin in a broken mode each. CI certifies both quivr plugin init templates and the Go SDK’s sample connector, and publishes their reports as the plugin-contract-report, subscription-plugin-contract-report, go-connector-contract-report and, for the Go SDK’s sample ingestion plugin, go-ingestion-contract-report and, for its sample retrieval plugin, go-retrieval-contract-report workflow artifacts. Write a connector with the Go SDK.