OGuardAI
Guides

Plugin and Extension Development

The real extension seams in OGuardAI, custom entities in policy, language pack overlays, and the NER backend interface

OGuardAI has no in-process plugin loader, no entry-point registry, and no hot-loaded native plugin SDK. Nothing is linked into the runtime from a directory at request time, so a dropped-in binary can never widen the trust boundary. "Extending" OGuardAI instead happens through explicit, reviewable seams at several altitudes, and every one of them is additive and fail-closed: a seam can only ADD or TIGHTEN detection, never weaken the built-in floor.

SeamYou changeCode?Restart?Where
Custom entity type, roles, channels, restore strategyPolicy YAMLNoReloadpolicies/<name>/policy.yaml
A custom pattern for a single requestRequest body custom_patternsNoNoPOST /v1/transform, /v1/detect
An external detector or an event webhookConfigNoYesoguardai.yaml detector.webhook, notifications
Detection vocab, language enrichment, restore labelsConfig and data (env-pointed JSON)NoYesoguardai.yaml, GUARDAI_LANGUAGE_PACKS, GUARDAI_RESTORE_TEMPLATES
Your own detection engine, in processA class you write, loaded by nameYour code, no forkYesNER_BACKEND=my_pkg.module:MyBackend
A new shipped backend name, a built-in detector, a new restore armRuntime sourceContributor agreementYesSee Contributing

Most of what people call a "plugin" for a data-protection runtime (a new entity, a new vertical, a new audience, a new language) is the top rows: policy, a request field, or config, no compile, no redeploy of the runtime image beyond a restart. This guide maps every seam an operator uses at deploy time, and goes deep on the one that genuinely needs code you write yourself (the NER backend interface).

For the day-to-day "add my own entity" path see Extending Entities. For the full catalog of config-only surfaces see Extensibility. Extending the runtime's own source (a new shipped backend, a built-in detector, dev setup, and PR rules) is a contributor path under the contributor agreement: see Contributing.

Seam 1: custom entity type (policy, no code)

A deployment declares its own domain identifiers (an MRN, a matter id, a bank account) under custom_patterns in policy YAML. Each match becomes {{custom:<type>:id}} and is governed by a normal rule. Custom patterns are additive: they can only add detections, never disable or weaken a built-in.

custom_patterns:
  - entity_type: "employee_id"
    pattern: '\bEMP-\d{6}\b'
    confidence: 0.9
    context_words: ["employee", "staff"]
rules:
  - entity_type: "employee_id"
    action: "tokenize"
    restore_mode: "masked"
    protection_level: 1
    conditions: []

That is the entire change, no fork. The full treatment (partial value_group, restore_strategies, NER-label types, the compatibility matrix) lives in Extending Entities. Promoting a detector into the shipped built-in floor is a runtime source change under the contributor agreement; see Contributing.

Seam: a custom pattern for a single request (no restart)

When a pattern is caller-specific or one-off, a request can carry its own custom_patterns without touching any policy or restarting the server. The patterns apply to that request only, are validated on use, and are never persisted:

POST /v1/transform
{
  "input": "Ticket TKT-4821 for erika.mustermann@firma.example",
  "policy": "default",
  "custom_patterns": [
    { "entity_type": "ticket_id", "pattern": "\\bTKT-\\d{4,6}\\b", "confidence": 0.9 }
  ]
}

The same validation the policy loader runs applies here: a pattern that matches everything is rejected, a type may not shadow a built-in named type, and the match merges additively (it fills gaps the built-in and NER detectors left, never overrides them). Two validate-only endpoints let a caller check a type name or a pattern before sending traffic: POST /v1/entity-types and POST /v1/patterns. Nothing is stored: this is a per-request extension, not registration.

Seam: an external HTTP detector (config)

A deployment can register an external detector over HTTP under detector.webhook in oguardai.yaml. Per request the runtime POSTs {text, language} to the endpoint and merges the returned spans additively, so a domain-specific detector (a model or service you own) augments the built-in and NER detectors without a fork:

detector:
  mode: both
  webhook:
    url: https://detector.internal/scan
    entity_types: [employee_id, case_ref]   # only these types are accepted
    api_key: ${DETECTOR_KEY}                 # sent as X-Detector-API-Key
    timeout_secs: 5
    required: false                          # true => a failure rejects the request

The plugin can only ADD coverage: any span it returns that overlaps a built-in or NER span is dropped, a declared type may not shadow a built-in named type, and an offset that disagrees with a returned value is rejected. Failure handling is explicit, and required controls only outage tolerance, not response integrity. A malformed or inconsistent response, an undeclared entity type, or a non-outage 4xx (for example 401, 422, or 429) fails the request closed in BOTH modes, because silently dropping a detector's corrupt coverage would fail open. A genuine outage (a timeout, a transport error, or a 5xx) fails the request closed when required: true, or degrades with a warning to the built-in floor when required: false. Invalid webhook config aborts startup rather than serving a half-configured detector.

Seam: push-webhook notifications (config)

Set notifications.webhook_url to receive the runtime's audit events at an external endpoint (a SIEM, an observability pipeline):

notifications:
  webhook_url: https://sink.internal/guardai-events
  api_key: ${SINK_KEY}                       # sent as X-Notification-Key
  events: [transform, policy_denied, session_expired]   # empty => all events

Delivery is fire-and-forget and fail-open: it runs on a spawned task, so a slow or down endpoint never blocks or fails a request, and the primary audit sink is unaffected. The payload is the same PII-free audit event the runtime already records: entity types and counts, never values, with the session id, trace id, and tenant id carried only as one-way fingerprints. It reuses the audit pipeline, so no request path emits an event twice.

Seam 2: language pack overlay (data, no source change)

OGuardAI ships enrichment lexicons for roughly 30 languages as an immutable data floor under python/detector-core/guardai_detector/languages/data/<code>.json. There are no per-language code modules: every language is data, driven by the generic DataEnricher. An operator adds, extends, or replaces a language at deploy time by pointing GUARDAI_LANGUAGE_PACKS at a JSON file that maps a language code to a lexicon. It loads at startup and a malformed pack fails loudly, it never silently no-ops.

{
  "_comment": "operator language packs",

  "yue": { "female_titles": ["女士"], "male_titles": ["先生"] },

  "de": { "male_names": ["acmebob"], "informal_markers": ["servus "] },

  "fr": { "_mode": "replace", "female_titles": ["Mme "], "male_titles": ["M. "] }
}

The optional _mode on each entry selects the operation:

  • No built-in for the code: the overlay is registered as a brand new language, as-is.
  • _mode absent or "extend" (the default): the overlay only widens the built-in. The built-in's gender and honorific results stay authoritative (the overlay fills gaps only, so a confident built-in classification is never flipped), new formality markers join the built-in's vote, and structural scalars (rtl, name_order, default_formality) keep the built-in value.
  • _mode: "replace": the built-in for that code is dropped and only the overlay applies. To truly override a built-in, start from its checked-in <code>.json, edit it, and load it with replace.

The lexicon schema (female_titles, male_titles, formal_markers, informal_markers, female_names, male_names, patronymic_suffixes, honorific_suffixes, rtl, name_order, default_formality) is documented in python/detector-core/guardai_detector/languages/data/README.md and validated by validate_lexicon at load. Programmatic equivalents exist for tests and embedding: LanguageRegistry.register_overlay(code, lexicon) for one entry and LanguageRegistry.load_overlays_from_path(path) for a file.

Two adjacent data seams round this out, both no-code:

  • spaCy model mapping. GUARDAI_SPACY_MODELS (JSON {language: model_name}) points a language at a different installed spaCy model or adds one, merged over the built-in map. Only relevant when the active backend is spacy.
  • Restore output. restore_templates in oguardai.yaml (or GUARDAI_RESTORE_TEMPLATES JSON for the proxy) localizes honorifics and abstract labels over the crates/rehydrate/data/restore_floor.yaml defaults. See Extensibility for both.

A language pack changes detection enrichment (gender, formality, honorifics), not which raw values are found. Detection vocabulary is a separate seam: detector.vocab in oguardai.yaml per deployment and custom_patterns per policy.

Seam 3: the NER backend interface

This is the one true "plugin" seam. A backend is selected by name, never discovered by scanning a directory, so nothing is loaded that an operator did not name. Two paths exist, and only the second touches OGuardAI source:

  • Your own engine, no fork. NER_BACKEND also accepts a module.path:ClassName spec. The detector imports and constructs that class, so a fine-tuned BERT or RoBERTa, Flair, Stanza, any HuggingFace model, a hosted NER API, or an in-house engine runs as the backend without changing a line of detector-core. See "Adding a new backend" below.
  • A new shipped backend name. Making gliner, spacy, none into a set of four is a runtime source change under the contributor agreement; see Contributing.

Either way the backend runs inside the detector sidecar and implements the same interface. If you would rather not run detection in process at all, the detector.webhook seam above puts it behind HTTP with no Python involved.

The interface

Every backend implements the NERBackend abstract base class in python/detector-core/guardai_detector/backends/base.py:

class NERBackend(ABC):
    @abstractmethod
    def detect(
        self, text: str, language: str = "en", extra_labels: list[str] | None = None
    ) -> list[DetectedEntity]: ...

    @abstractmethod
    def is_available(self) -> bool: ...

    @abstractmethod
    def name(self) -> str: ...

    def available_languages(self) -> list[str]:
        return []          # override if you have per-language models (spaCy does)

    def supports_zero_shot(self) -> bool:
        return False       # override to True if you honor per-request extra_labels

Contract notes that matter:

  • detect returns DetectedEntity objects with entity_type, text, start, end, confidence, and metadata. Entity types must be OGuardAI types (person, company, location, address); map your engine's labels yourself, the way SpaCyBackend maps PERSON/ORG/GPE. The pattern-based detector runs separately and its results are merged, so a backend returns NER entities only.
  • extra_labels are per-request zero-shot labels a policy asks for via detection.ner_labels. If your engine supports zero-shot, honor them and set supports_zero_shot() to True; report a per-request label back under its own name (the Rust client remaps it per policy). If not, ignore them and leave the default False, which makes the runtime fail closed on a required label-derived type rather than under-detect.
  • available_languages() defaults to empty on purpose: a language-general engine like GLiNER declares no per-language models, and the detector derives coverage from the enrichment registry instead. Only override it if you load language-specific models.
  • is_available() must reflect real readiness (model loaded, dependency present). Load failures are expected and handled by the selector below, so return False rather than raising in the constructor.

The three shipped backends

BackendClassFileZero-shotPer-language models
glinerGLiNERBackendbackends/gliner_backend.pyYesNo (language-general)
spacySpaCyBackendbackends/spacy_backend.pyNoYes
noneNoneBackendbackends/none_backend.pyNoNo (returns nothing)

none is the regex-only mode: NER returns nothing and only the pattern detector runs. It is the honest floor, not a stub to fill in.

How a backend is selected

DetectorConfig.ner_backend comes from the NER_BACKEND env var (default gliner). PIIDetector._init_backend in python/detector-core/guardai_detector/detector.py accepts either a built-in name validated against _KNOWN_BACKENDS = {"gliner", "spacy", "none"}, or a module.path:ClassName spec that it imports and constructs (load_custom_backend). Under the strict default (NER_BACKEND_STRICT on), a requested backend that fails to load aborts startup instead of falling back: a built-in gliner or spacy that cannot load, as with a custom backend that fails, refuses to start rather than silently running a weaker engine. With the opt-out (NER_BACKEND_STRICT=false) an unavailable built-in degrades down the chain gliner -> spacy -> none, and a failed custom backend degrades to the regex-only floor, never silently to a different engine. The shipped compose and Helm chart pin NER_BACKEND_STRICT explicitly.

Adding a new backend

No fork (a deployment's own engine). Put your NERBackend subclass on the detector service's import path and set NER_BACKEND=my_pkg.module:MyBackend (or route a single language to it via GUARDAI_NER_LANG_BACKENDS). The class must construct with no required arguments, reading its model, endpoint, or API key from the environment, and return False from is_available() until it is ready. The detector imports and constructs it (load_custom_backend); a load failure aborts under NER_BACKEND_STRICT or else degrades to the regex-only floor. This is the plug-and-play path for a proprietary model, a hosted NER, or a different GLiNER wrapper, no source change to detector-core.

In tree (shipping it with detector-core). Promoting a backend to a first-class built-in name edits the proprietary detector-core source, so it is a contributor path, not a deployment one. The steps (the backends/ class, the _KNOWN_BACKENDS registration and _init_backend branch, any DetectorConfig fields, and the backend test) live in CONTRIBUTING.md under "Extending the Runtime". The rest of the pipeline (dedup, enrichment, the merge with regex, the Rust-side MergedDetector) is untouched either way: it consumes DetectedEntity and does not care which backend produced it.

You usually do not need a new backend

Before writing one, confirm the dynamic seams do not already cover you. A new vertical NER label (a diagnosis, a matter subject) is zero-shot on the existing GLiNER backend via a policy's detection.ner_labels, no code. A different local spaCy model for a language is GUARDAI_SPACY_MODELS, no code. A new backend is justified only when you are swapping the detection engine itself, for example a proprietary or hosted model.

What does not exist (by design)

Do not document or expect these; they are deliberately absent so a config file cannot weaken the trust model:

  • No plugin discovery: nothing is found by scanning a directory or reading an entry-point registry, and no native library is hot-loaded. Every backend is named explicitly by the operator. The shipped names are a validated set (gliner, spacy, none) and a fourth shipped name is a source change; a backend of your own is named as NER_BACKEND=module.path:ClassName and an external detector is named as a detector.webhook URL. Both are config-declared, fail-closed, and additive-only.
  • No config-defined policy actions. redact, tokenize, and abstract are a closed, security-reviewed set. A novel action (route, quarantine, notify) is code, not a knob. A raw pass-through is expressed via the policy whitelist, not a new action.
  • No config-defined restore modes. full, formatted, partial, masked, abstract, and none are closed. restore_strategies customize the partial reveal without code, but a brand new mode is code.
  • No config-defined token format. A token renders as {{type:id}}, or {{type:id:cap}} when a capability cap is present ({{custom:type:id}} / {{custom:type:id:cap}} for a custom type). Nothing else is emitted and the shape is not configurable.

Everything domain-specific (entity types, entity roles, caller roles, output channels, egress destinations, the RAG classification lattice, NER labels, languages, the partial-restore algorithm) is yours to define in policy and data. The fixed core is exactly the part a deployment should not be able to loosen from outside the runtime. See the "What requires code" section of Extensibility for the reasoning behind each line.