Hello, plugin
This tutorial walks you end-to-end through the work of adding a new plugin to AiSOC. By the end you will have:
- An
aisoc-plugin.yamlmanifest the plugin loader can validate. - A
plugin.pythat subclassesEnricherPluginand exposes acreate_plugin()factory. - A working
on_load()lifecycle hook that reads tenant config out ofPluginContext. - A working
enrich()method that returns a typedEnrichmentResultthe platform can write to indicator records. - A
PluginRegistryflow that mirrors what the AiSOC runtime does at boot. - A smoke test that pins all of the above against the real loader, so the docs page can never silently drift away from the code.
The example enricher is intentionally trivial — it computes a deterministic SHA-256 hash of the indicator value and returns the digest as enrichment metadata. There is no network call, no external API, no credential. That keeps the tutorial:
- runnable in air-gapped environments,
- reproducible in CI without secrets,
- focused on the contract rather than on third-party authentication,
- safe to copy as a starting point — when you swap the hash for a vendor SDK the manifest and entry-point shape stay identical.
When you're ready to write a real enricher, copy the example, replace the hashing call with whatever vendor SDK you need, and keep the manifest + create_plugin factory shape exactly as-is.
Where the example lives
The reference plugin and its smoke test live at:
plugins/community/_examples/hello-plugin/aisoc-plugin.yaml
plugins/community/_examples/hello-plugin/plugin.py
plugins/community/_examples/hello-plugin/README.md
packages/plugin-sdk-py/tests/test_hello_plugin_example.py
The _examples/ directory is deliberately not picked up by scripts/build_marketplace.py. The marketplace builder scans plugins/community/<id>/plugin.yaml, and the tutorial avoids both signals on purpose:
- It lives one directory deeper, under
_examples/hello-plugin/. - Its manifest is named
aisoc-plugin.yaml(the SDK loader filename), notplugin.yaml(the marketplace filename).
That means the example can never accidentally ship to a real tenant. The smoke test pins this invariant — if anyone "fixes" either of those, CI fails. The same pattern is used by Hello, connector and Hello, hunt.
Plugin types — pick the right base class first
The Python SDK ships three plugin types, each with a different contract:
| Type | Base class | Contract method | Use it when… |
|---|---|---|---|
enricher | EnricherPlugin | async def enrich(req, ctx) -> EnrichmentResult | You take an indicator (IP, domain, URL, hash, email) and return structured intel about it. |
action | ActionPlugin | async def execute(req, ctx) -> ActionResult | You take a response action (block IP, isolate host, page on-call) and report success or failure back. |
connector | ConnectorPlugin (plugin) | async def fetch_alerts(...) | You poll a vendor API and emit normalized alerts. For first-class connectors use BaseConnector instead — the connector plugin type exists for marketplace-shipped third-party integrations. |
This tutorial sticks to enricher because it's the simplest contract — one input record in, one output record out, no scheduler, no retry policy, no normalisation. When you've internalised the manifest + create_plugin() shape here, the action and connector plugin types will feel familiar.
Step 1 — Pick a stable plugin id
id: aisoc.hello-plugin
Three rules to internalise:
- Lowercase, dotted, no spaces. The
idis a wire identifier — it ends up inmarketplace/index.json, in tenant install records, and in audit logs whenever the registry resolves an enricher. Treat it like a primary key. - Prefix with your namespace. Use
<your-org>.or<your-handle>.so two contributors don't shipvt-enricherand collide. Theaisoc.prefix is reserved for first-party tutorial and reference plugins; real contributions useacme.virustotalorjdoe.greynoise. - Never change it after merge. Renaming a plugin orphans every tenant install that references the old id. If the plugin needs a v2 with breaking config changes, give it a new id and deprecate the old one — the marketplace publishing flow has a
deprecatedfield for exactly this reason. See Publishing plugins.
The example uses aisoc.hello-plugin because the AiSOC project itself is the author.
Step 2 — Write the manifest
The manifest is parsed by load_manifest and validated against PluginManifest. Anything that doesn't match the schema is rejected at load time with a PluginLoadError.
id: aisoc.hello-plugin
name: Hello Plugin (Tutorial)
version: 1.0.0
plugin_type: enricher
description: >
Tutorial enricher that hashes the indicator value with SHA-256 locally.
Reference implementation for apps/docs/docs/plugins/hello-plugin.md.
Deliberately offline so it can run in air-gapped environments and CI
without any external API calls.
author: AiSOC Tutorial
tags:
- tutorial
- enricher
- offline
A few non-obvious things:
plugin_typeis regex-validated. The schema enforces^(enricher|action|connector)$. Misspelling it (enrichers,Enricher,connect) fails the load with a Pydantic validation error before yourplugin.pyis even imported.versionis treated as SemVer. The marketplace orders installable versions by SemVer comparison, and the publishing flow uses it to decide whether a tenant has an upgrade available. Use real SemVer (1.0.0,1.0.1,2.0.0-rc.1) — calendar versions like2025.05will sort but won't trigger upgrade notifications cleanly.tagsare free-form and mostly used for marketplace filtering. There is no enforced taxonomy (yet). Keep them lowercase and hyphen-separated for consistency with the connectors layer.- Two manifest filenames exist. The SDK loader (
load_manifest) readsaisoc-plugin.yaml. The marketplace builder (scripts/build_marketplace.py) readsplugin.yaml. The two formats are nearly identical, but the marketplace one carries extra publish metadata (signature URL, registry URL, install instructions). When you graduate from_examples/toplugins/community/<your-id>/, you will write both files. See Publishing plugins for the marketplace shape.
Step 3 — Implement the plugin class
Now the code. Two ways to do this — class-based and decorator-based. The tutorial uses the class-based path because it makes the on_load/on_unload lifecycle hooks explicit, which you'll want as soon as a real enricher needs an HTTP client or a cached secret.
from __future__ import annotations
import hashlib
from aisoc_plugin_sdk import (
AiSOCPlugin,
EnricherPlugin,
EnrichmentRequest,
EnrichmentResult,
PluginContext,
PluginManifest,
)
class HelloPlugin(EnricherPlugin):
"""Deterministic, offline enricher used by the hello-plugin tutorial."""
@property
def manifest(self) -> PluginManifest:
return PluginManifest(
id="aisoc.hello-plugin",
name="Hello Plugin (Tutorial)",
version="1.0.0",
description=(
"Tutorial enricher that hashes indicator values locally. "
"Reference implementation for "
"apps/docs/docs/plugins/hello-plugin.md."
),
author="AiSOC Tutorial",
tags=["tutorial", "enricher", "offline"],
plugin_type="enricher",
)
Three things to call out:
manifestis a@property, not a method. The SDK definesmanifestas@property @abstractmethodonAiSOCPlugin. If you writedef manifest(self)instead of@property def manifest(self), the registry will receive a bound method instead of aPluginManifestinstance and every lookup will explode at runtime. The smoke test pins the property contract.- The manifest values must mirror
aisoc-plugin.yaml. The loader trusts the YAML for discovery, but every code path past the load step reads the manifest off the plugin instance. If the two drift, the marketplace will list one version and the runtime will report another. Keep them in lockstep, or have a build step that reads the YAML and constructs the manifest from it. - You're inheriting from
EnricherPlugin, notAiSOCPlugindirectly. This is what makes the registry route enrichment requests to your plugin. Inheriting from the wrong base class is a silent bug — the plugin loads fine, registers fine, and never receives any enrichment work.
Step 4 — Implement the lifecycle hook
on_load(ctx) is called exactly once, after the plugin is registered and before the runtime sends it any work. The PluginContext argument carries the API base URL, a scoped API token, and the tenant's plugin config dict.
async def on_load(self, ctx: PluginContext) -> None:
self._algorithm = (ctx.config.get("algorithm") or "sha256").lower()
if self._algorithm not in hashlib.algorithms_guaranteed:
raise ValueError(
f"Unsupported hash algorithm: {self._algorithm!r}. "
f"Pick one of: {sorted(hashlib.algorithms_guaranteed)}"
)
What's happening:
ctx.configis the per-tenant configuration. Whatever the tenant set in their plugin install settings ends up here. The tutorial reads a single optionalalgorithmkey — a real enricher might read API endpoints, rate-limit budgets, or per-customer feature flags.- The hook can raise. If
on_loadraises, the runtime marks the plugin install as failed and surfaces the error message to the tenant. Use this to fail fast on bad config — empty API keys, unreachable endpoints, malformed allow-lists. Failing here is cheaper than failing later inenrich(), where the same error will repeat for every indicator. - There is also
on_unload(). It's called when the plugin is uninstalled or when the runtime shuts down. The tutorial doesn't override it because there's nothing to clean up. A real enricher with a long-livedhttpx.AsyncClientshouldawait client.aclose()here.
Step 5 — Implement enrich()
This is the actual work the platform calls.
async def enrich(
self, request: EnrichmentRequest, ctx: PluginContext
) -> EnrichmentResult:
algorithm = getattr(self, "_algorithm", "sha256")
digest = hashlib.new(algorithm, request.indicator_value.encode("utf-8")).hexdigest()
return EnrichmentResult(
indicator_type=request.indicator_type,
indicator_value=request.indicator_value,
enrichments={
"hello_plugin.algorithm": algorithm,
"hello_plugin.digest": digest,
"hello_plugin.length": len(digest),
},
tags=["hello-plugin"],
malicious=None,
confidence=None,
raw={"input": request.indicator_value, "digest": digest},
)
The contract:
request.indicator_typeandrequest.indicator_valuecome from the indicator that triggered enrichment. The five types AiSOC routes today areip | domain | url | hash | email. A real enricher should branch onindicator_typeand short-circuit (or return an empty result) for types it doesn't support — the tutorial hashes everything because hash-of-anything is well-defined.enrichmentsis a flat dict that's merged into the indicator record. Namespace your keys with<plugin-id>.<field>(the tutorial useshello_plugin.*) so two enrichers writing to the same indicator can't stomp each other.tagsare appended to the indicator's tag list. Use them for downstream filtering — e.g.["malicious", "vt-detected"]or["benign", "alexa-top-1k"].maliciousis a tri-state.Truemeans the enricher is confident it's bad.Falsemeans the enricher is confident it's clean.Nonemeans the enricher has no opinion. Don't returnFalsejust because your API returned no hits — that's an opinion you don't have. The tutorial returnsNonebecause hashing a value tells you nothing about its reputation.confidenceis[0.0, 1.0]orNone. Pydantic enforces the range; out-of-band values raise at construction time. Skip the field unless your upstream actually returns a confidence score.rawis for audit. Store the upstream API response (or a redacted version) so investigators can reproduce the decision later. Don't put secrets in here — the indicator record is readable by anyone with case access.
Step 6 — Expose create_plugin()
The loader doesn't import your class directly. It looks for a top-level create_plugin() factory in plugin.py and uses whatever it returns:
def create_plugin() -> AiSOCPlugin:
"""Factory called by ``load_plugin_from_directory``."""
return HelloPlugin()
Why a factory and not the class itself:
- Per-tenant isolation. Every tenant install gets its own plugin instance, so per-tenant state (cached HTTP client, last-seen timestamp, rate-limit bucket) lives on the instance and can't leak across tenants.
- Lazy construction. The class can defer expensive work (loading a model, opening a file) until the runtime actually needs it. The loader pays the cost of
create_plugin(); the import ofplugin.pystays cheap. - Test-friendly. The smoke test calls
load_plugin_from_directory(...)exactly the way the runtime does. Ifcreate_pluginis missing, returnsNone, or returns something that isn't anAiSOCPlugin, the loader raisesPluginLoadErrorwith a precise message. You don't need to mock anything.
Step 7 — Run the smoke test
The companion smoke test lives at packages/plugin-sdk-py/tests/test_hello_plugin_example.py and pins the entire contract: the files exist, the example is excluded from the marketplace, the loader accepts the manifest + factory shape, the lifecycle hook validates config, and the enrichment is deterministic.
cd packages/plugin-sdk-py
.venv/bin/python -m pytest tests/test_hello_plugin_example.py -v
Expected output:
tests/test_hello_plugin_example.py::test_hello_plugin_example_files_exist PASSED
tests/test_hello_plugin_example.py::test_hello_plugin_is_excluded_from_marketplace PASSED
tests/test_hello_plugin_example.py::test_hello_plugin_loads_via_loader PASSED
tests/test_hello_plugin_example.py::test_on_load_defaults_to_sha256 PASSED
tests/test_hello_plugin_example.py::test_on_load_accepts_configured_algorithm PASSED
tests/test_hello_plugin_example.py::test_on_load_rejects_unknown_algorithm PASSED
tests/test_hello_plugin_example.py::test_enrich_is_deterministic PASSED
tests/test_hello_plugin_example.py::test_enrich_uses_configured_algorithm PASSED
tests/test_hello_plugin_example.py::test_hello_plugin_registers_as_enricher PASSED
9 passed
The two tests worth understanding before you write your own plugin:
test_hello_plugin_loads_via_loader
def test_hello_plugin_loads_via_loader() -> None:
plugin = load_plugin_from_directory(HELLO_PLUGIN_DIR)
assert isinstance(plugin, EnricherPlugin)
assert plugin.manifest.id == "aisoc.hello-plugin"
assert plugin.manifest.plugin_type == "enricher"
This is the single most useful test you can write for a plugin. It calls load_plugin_from_directory exactly the way the runtime does, which proves:
- the manifest YAML is parseable and schema-valid,
- the entry point file exists and is importable,
create_plugin()is defined and returns the expected base class,- the manifest property returns a real
PluginManifest(not a method, notNone).
If this test passes, the runtime will be able to load your plugin. If it fails, the error message points at exactly which contract you broke.
test_enrich_is_deterministic
async def test_enrich_is_deterministic(ctx: PluginContext) -> None:
plugin = load_plugin_from_directory(HELLO_PLUGIN_DIR)
await plugin.on_load(ctx)
request = EnrichmentRequest(
indicator_type="ip", indicator_value="203.0.113.42",
)
expected_digest = hashlib.sha256(b"203.0.113.42").hexdigest()
result_a = await plugin.enrich(request, ctx)
result_b = await plugin.enrich(request, ctx)
assert result_a.enrichments["hello_plugin.digest"] == expected_digest
assert result_a.model_dump() == result_b.model_dump()
Two reasons this matters:
- The same input must always yield the same enrichment. That's what makes the result cacheable, replayable, and trustworthy in case investigations. If your enricher hits a vendor API, mock the API in the test (with
respxorpytest-httpx) so the test stays deterministic — you're testing your code, not the vendor's uptime. - Snapshot equality on
model_dump()is the cheapest way to catch accidental breaking changes. If you add a new key toenrichments, this assertion still passes (because it's the same on both calls). If you start mutating shared state across calls, it fails immediately.
Step 8 — Register with the runtime
In production, the AiSOC plugin runtime constructs a PluginRegistry, loads every installed plugin from disk, and calls load_all() once per tenant. The smoke test mirrors this so you can validate it locally:
async def test_hello_plugin_registers_as_enricher(ctx: PluginContext) -> None:
plugin = load_plugin_from_directory(HELLO_PLUGIN_DIR)
registry = PluginRegistry()
registry.register(plugin)
await registry.load_all(ctx)
assert len(registry) == 1
enrichers = registry.enrichers()
assert len(enrichers) == 1
assert enrichers[0].manifest.id == "aisoc.hello-plugin"
assert registry.get("aisoc.hello-plugin") is plugin
Three things this proves:
PluginRegistry.register()accepts the plugin and stores it undermanifest.id. Registering the same id twice raises — the runtime relies on this to surface duplicate installs.load_all()callson_load(ctx)for every plugin and propagates exceptions. If youron_loadraises, the registry stays in a half-loaded state and the runtime surfaces the error to the tenant.enrichers()returns onlyEnricherPlugininstances. The same registry can hold actions and connectors side-by-side; the type-segmented accessors (enrichers(),actions(),connectors()) are how the runtime routes work.
What you don't get from the tutorial
The tutorial is intentionally narrow. Real plugins eventually need:
- Authentication. Most real enrichers need an API key or OAuth token. Read it from
ctx.config, never from env vars — env vars are global and the runtime sets them per-process, not per-tenant. The credential vault encrypts secrets at rest; see Operations → Credentials. - An HTTP client. Open one
httpx.AsyncClientinon_load, store it onself, reuse it from everyenrich(), and close it inon_unload. Don't open a fresh client per request — connection pooling matters even for low-volume enrichers. - Error handling. The current
enrich()will raise if the algorithm is missing or invalid. A real enricher should catch upstream API errors, classify them (timeout vs. 4xx vs. 5xx), and either return an emptyEnrichmentResultor raise — the runtime treats unhandled exceptions as fatal for the request, not for the plugin. - Rate limiting. If your vendor enforces a request-per-second budget, enforce it in the plugin with
asyncio.Semaphoreoraiolimiter. The runtime won't do it for you, and bursting will get the tenant's API key throttled or banned. - Observability. The
AiSOCClient(exported fromaisoc_plugin_sdk) gives you authenticated access to the AiSOC API for emitting plugin-side events and metrics. Use it sparingly — every call goes back over the network.
When you wire any of these in, the contract you wrote in this tutorial — manifest, create_plugin(), on_load, enrich, registry — does not change. That's the value of the SDK.
Graduating from _examples/ to the marketplace
When you're ready to ship the plugin to real tenants:
- Copy
plugins/community/_examples/hello-plugin/toplugins/community/<your-id>/. - Rename
aisoc-plugin.yaml→plugin.yamland add the marketplace fields (signature_url,registry_url,install_command). The full schema lives in Publishing plugins. - Sign the plugin with your maintainer Ed25519 key (
scripts/sign_plugin.py). - Add an entry to
marketplace/index.jsonand runpnpm marketplace:sync. - Open a PR. Maintainers will review the plugin, validate the signature against your registered public key, and merge.
After merge the plugin shows up in every tenant's in-app marketplace under the Community badge. Tenants install it with one click, and the runtime calls the same load_plugin_from_directory() you tested locally.
That's the whole loop.