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.
| Seam | You change | Code? | Restart? | Where |
|---|---|---|---|---|
| Custom entity type, roles, channels, restore strategy | Policy YAML | No | Reload | policies/<name>/policy.yaml |
| A custom pattern for a single request | Request body custom_patterns | No | No | POST /v1/transform, /v1/detect |
| An external detector or an event webhook | Config | No | Yes | oguardai.yaml detector.webhook, notifications |
| Detection vocab, language enrichment, restore labels | Config and data (env-pointed JSON) | No | Yes | oguardai.yaml, GUARDAI_LANGUAGE_PACKS, GUARDAI_RESTORE_TEMPLATES |
| Your own detection engine, in process | A class you write, loaded by name | Your code, no fork | Yes | NER_BACKEND=my_pkg.module:MyBackend |
| A new shipped backend name, a built-in detector, a new restore arm | Runtime source | Contributor agreement | Yes | See 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 requestThe 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 eventsDelivery 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.
_modeabsent 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 withreplace.
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 isspacy. - Restore output.
restore_templatesinoguardai.yaml(orGUARDAI_RESTORE_TEMPLATESJSON for the proxy) localizes honorifics and abstract labels over thecrates/rehydrate/data/restore_floor.yamldefaults. 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_BACKENDalso accepts amodule.path:ClassNamespec. 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 ofdetector-core. See "Adding a new backend" below. - A new shipped backend name. Making
gliner,spacy,noneinto 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_labelsContract notes that matter:
detectreturnsDetectedEntityobjects withentity_type,text,start,end,confidence, andmetadata. Entity types must be OGuardAI types (person,company,location,address); map your engine's labels yourself, the waySpaCyBackendmapsPERSON/ORG/GPE. The pattern-based detector runs separately and its results are merged, so a backend returns NER entities only.extra_labelsare per-request zero-shot labels a policy asks for viadetection.ner_labels. If your engine supports zero-shot, honor them and setsupports_zero_shot()toTrue; report a per-request label back under its own name (the Rust client remaps it per policy). If not, ignore them and leave the defaultFalse, 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 returnFalserather than raising in the constructor.
The three shipped backends
| Backend | Class | File | Zero-shot | Per-language models |
|---|---|---|---|---|
gliner | GLiNERBackend | backends/gliner_backend.py | Yes | No (language-general) |
spacy | SpaCyBackend | backends/spacy_backend.py | No | Yes |
none | NoneBackend | backends/none_backend.py | No | No (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 asNER_BACKEND=module.path:ClassNameand an external detector is named as adetector.webhookURL. Both are config-declared, fail-closed, and additive-only. - No config-defined policy actions.
redact,tokenize, andabstractare a closed, security-reviewed set. A novel action (route, quarantine, notify) is code, not a knob. A raw pass-through is expressed via the policywhitelist, not a new action. - No config-defined restore modes.
full,formatted,partial,masked,abstract, andnoneare closed.restore_strategiescustomize thepartialreveal 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.