Authoring Plugins¶
Imbi plugins extend the platform with integrations against external
systems. Under Plugin Architecture v3 (api_version = 2) a Python
package ships exactly one Plugin subclass whose manifest
declares — once per package — the integration-level options and
credentials plus a set of capabilities. Each capability binds one
enumerated behavior (deployment, logs, identity, …) to an implementation
class. The host services (imbi-api, and imbi-gateway for webhook
actions) discover, validate, and instantiate plugins at runtime; plugins
carry no global state and receive everything they need — context,
credentials, and options — on every call.
An Integration graph node is a configuration instance of a plugin:
it stores the resolved integration-level option values and the single
encrypted credential blob. One plugin backs many Integrations. (The
Integration node is the connection — there is no separate connection
plugin type.)
This page covers the parts every plugin shares: discovery, the manifest,
the Capability model, the request context, credential decryption, the
registry, errors, templates, and plugin-declared graph schema. Each
capability kind then has its own page documenting the contract base class
it implements and the data models it exchanges with the host.
One Plugin per Package¶
A minimum plugin distribution contains:
- One
Pluginsubclass whose class-levelmanifest: PluginManifestdeclares the package's slug, name, integration-level options and credentials, and its capabilities (each binding a handler class). - A module-level
PLUGINattribute in the package's root__init__, pointing at thatPluginsubclass, so the convention scan can discover it.
# src/imbi/plugins/github/__init__.py
from imbi.plugins.github.plugin import GitHubPlugin
PLUGIN = GitHubPlugin
Discovery¶
load_plugins() finds plugins three ways, unions them, and dedupes by
slug:
- First-party scan — every subpackage of
imbi.plugins(the plugins shipped with theimbidistribution) is imported and its module-levelPLUGINattribute (aPluginsubclass) is read. Plugin code always ships; a plugin whose optional dependencies are not installed (see theimbi[plugin-*]extras) is recorded as skipped, not as an error. Individual plugins can be turned off by slug with theIMBI_PLUGINS_DISABLEDsetting (comma-separated env var or list). - Convention scan — every installed top-level module named
imbi_plugin_*is imported and its module-levelPLUGINattribute (aPluginsubclass) is read. This is the packaging convention for third-party plugins. - Explicit registration — the
IMBI_PLUGINSsetting (env varIMBI_PLUGINS, a comma-separated list — or JSON list — of dotted import paths such asmycorp.imbi.jira:JiraPlugin) covers packages that cannot follow the naming convention. It is read viaimbi.common.settings.Plugins().imbi_plugins.
There is no entry-point group: the only source of truth is the class hierarchy, and discovery is a mechanical scan. Editable installs and monorepo dev work without re-running metadata hooks.
The Manifest¶
Every plugin declares a class-level manifest. Integration-level options
and credentials are declared once; capabilities are
enabled/configured per Integration. The host treats the manifest as
immutable once registered.
from imbi.common.plugins import (
Capability,
CredentialField,
Plugin,
PluginManifest,
PluginOption,
)
from imbi.plugins.github.capabilities import (
GitHubDeployment,
GitHubIdentity,
GitHubLifecycle,
GitHubWebhookActions,
GitHubCommitSync,
GitHubPullRequestSync,
)
class GitHubPlugin(Plugin):
manifest = PluginManifest(
slug='github',
name='GitHub',
description='Repositories, deployments, and identity on GitHub.',
# Brand icon in `library-icon-name` form (Simple Icons, Tabler,
# Lucide, …). Surfaced to the UI to show the provider's logo.
icon='si-github',
api_version=2,
auth_type='oauth2',
# Integration-level options — asked ONCE per Integration.
options=[
PluginOption(
name='flavor',
label='Flavor',
choices=['github', 'ghec', 'ghes'],
required=True,
),
PluginOption(name='host', label='Host'),
],
# Integration-level credentials — the ONLY credential declaration.
credentials=[
CredentialField(name='app_id', label='App ID', secret=False),
CredentialField(name='private_key', label='Private key'),
CredentialField(
name='installation_id',
label='Installation ID',
secret=False,
),
CredentialField(
name='client_id',
label='OAuth client ID',
required=False,
secret=False,
),
CredentialField(
name='client_secret',
label='OAuth client secret',
required=False,
),
],
capabilities=[
Capability(
kind='identity',
label='Sign in with GitHub',
default_enabled=False,
project_scoped=False,
hints={'login_capable': True},
handler=GitHubIdentity,
),
Capability(
kind='deployment',
label='Deployments',
hints={'supports_deployment_sync': True},
handler=GitHubDeployment,
),
Capability(
kind='lifecycle',
label='Repository lifecycle',
hints={
'supports_lifecycle_sync': True,
'lifecycle_events': ['created', 'archived', 'deleted'],
},
handler=GitHubLifecycle,
),
Capability(
kind='webhook-actions',
label='Webhook actions',
handler=GitHubWebhookActions,
),
Capability(
kind='commit-sync',
label='Commit history sync',
handler=GitHubCommitSync,
),
Capability(
kind='pr-sync',
label='Pull request sync',
handler=GitHubPullRequestSync,
),
],
)
Field notes¶
slugis the canonical identifier. Two plugins with the same slug cannot coexist; the second is rejected at load.api_versionmust be2. Plugins declaring other versions are skipped and reported as such, not errored.auth_typedeclares the credential flow:'api_token'(default),'oauth2','oidc','aws-iam-ic', or'none'. OAuthclient_id/client_secretare ordinary named fields incredentials.optionsare the integration-level option values collected once per Integration (e.g. GitHubflavor/host, AWSregion). Five field types are supported: the four scalars (string,integer,boolean,secret) plusmapping(a key/value editor stored asdict[str, str]). Resolved values reach a capability viaPluginContext.integration_options.credentialsare the only credential declaration in the package — capabilities cannot declare their own. Values are encrypted at rest as the Integration's single blob and decrypted into thecredentialsdict passed to every call. Each field issecret=Trueby default (masked in the UI, never echoed back); setsecret=Falsefor non-sensitive identifiers such as an OAuthclient_idor a GitHub App id so the UI renders them as plain text. Each field also acceptsmultiline, which defaults toFalse; set it toTruefor values that span multiple lines (e.g. a PEM private key) so the UI renders a textarea instead of a single-line input.capabilitiesmust declare at least oneCapability, and eachkindmust be unique within the manifest.data_typesapply to configuration capabilities — see Configuration.vertex_labels/edge_labelslet a plugin extend the AGE graph with its own reference data (see Plugin-declared Graph Schema).ops_log_templatessupply Mustache-style templates the UI uses to render operations-log entries tagged with the plugin's slug, keyed by theactionvalue the host wrote into the entry.
Plugin ¶
Bases: ABC
One per package. The :attr:manifest — including each capability's
handler binding — is the complete declaration; the class itself holds
no behavior and no separate capability map.
PluginManifest ¶
Bases: BaseModel
The complete declaration of a plugin package.
Integration-level options and credentials are declared once; capabilities are enabled/configured per Integration.
get_capability ¶
Return the capability of kind, or None when absent.
Source code in libraries/common/src/imbi/common/plugins/base.py
PluginOption ¶
Bases: BaseModel
CredentialField ¶
Bases: BaseModel
DataType ¶
Bases: BaseModel
OpsLogTemplate ¶
Bases: BaseModel
Plugin-supplied formatter for an operations-log entry.
Keyed off the action value the API writes into the ops-log
description payload (e.g. promote / deploy for
deployment capabilities, set_value / delete_key for config
capabilities). The UI substitutes {{name}} placeholders against
the payload merged with row-level fields (version,
environment, project, performer).
PluginVertexLabel ¶
Bases: BaseModel
PluginEdgeLabel ¶
Bases: BaseModel
PluginIndex ¶
Bases: BaseModel
Capabilities¶
A Capability both declares operator-facing metadata (label,
options, defaults) and binds the implementation via its handler —
a class subclassing the contract base for the capability's kind. It is
validated at construction: the handler must subclass the correct contract
(CAPABILITY_CONTRACTS[kind]) and every hints key must be in the
per-kind allowlist (HINT_ALLOWLIST).
Key fields:
kind— one of the enumeratedCapabilityKindvalues below.label/description— operator-facing text.options— capability-scopedPluginOptions, rendered under the capability's toggle in the Integration form; values reach a call viaPluginContext.capability_options.default_enabled— initial toggle state when an Integration is created.project_scoped— whether the capability participates in per-project-type / per-projectUSESassignment.identity, for example, is Integration-wide, not project-scoped.requires_identity— the capability wantsctx.identitypopulated when available.hints— kind-specific hints, validated againstHINT_ALLOWLIST(see table). These are the v1 manifest booleans, moved onto the capability.ui_module— RESERVED: package-relative path to a built ESM module the UI can load for the capability;Noneuses built-in UI.handler— the implementation class. Excluded from serialization; the manifest that reaches the API/UI is pure data.
Kinds, surfaces, and contracts¶
Each kind maps to exactly one contract ABC (CAPABILITY_CONTRACTS) and
a fixed set of surfaces (CAPABILITY_SURFACES). Adding a kind is a
deliberate base-model change.
kind |
Contract base | Surfaces | Page |
|---|---|---|---|
configuration |
ConfigurationCapability |
ui, api | Configuration |
logs |
LogsCapability |
ui, api | Logs |
identity |
IdentityCapability |
api | Identity |
deployment |
DeploymentCapability |
ui, api | Deployment |
lifecycle |
LifecycleCapability |
api | Lifecycle |
webhook-actions |
WebhookActionsCapability |
webhook | Webhook Actions |
analysis |
AnalysisCapability |
ui, api | Analysis |
incidents |
IncidentsCapability |
ui, api | Incidents |
commit-sync |
CommitSyncCapability |
api, webhook | Commit Sync |
pr-sync |
PullRequestSyncCapability |
api, webhook | Pull Request Sync |
tools |
ToolsCapability |
tools | Tools |
Hint allowlist¶
cacheable is accepted for every kind (a hint the host may consult to
cache a capability's reads). The remaining keys are per-kind:
kind |
Additional allowed hints |
|---|---|
logs |
supports_histogram |
deployment |
supports_deployment_sync |
lifecycle |
supports_lifecycle_sync, lifecycle_events |
identity |
login_capable, default_scopes, widget_text |
An unknown hint key fails at manifest construction, so typos surface at load.
Capability ¶
Bases: BaseModel
One capability of a plugin — declaration plus handler binding.
A Capability both declares operator-facing metadata (label,
options, defaults) and binds the implementation via
:attr:handler, mirroring how :class:ActionDescriptor binds a
webhook action's callable. It is validated at construction: the
handler must subclass the contract ABC for :attr:kind
(:data:CAPABILITY_CONTRACTS) and every :attr:hints key must be in
the per-kind allowlist (:data:HINT_ALLOWLIST).
CapabilityHandler ¶
Bases: ABC
Base for all capability implementations.
Stateless; a new instance is created per request. Every method
receives (ctx: PluginContext, credentials: dict[str, str], ...)
where credentials is the Integration's decrypted blob — always
the same blob for every capability of the Integration.
Plugin Context¶
Every capability call receives a PluginContext carrying the resolved
project's identity plus host-populated fields the capability can derive
state from. A handful of fields are write-only side channels the
capability sets and the host reads back after the call — most notably
link_writeback and service_writeback.
Resolved configuration reaches a call on three layers:
integration_options— the Integration's resolved integration-level option values (e.g. GitHub host/flavor, AWS region), so a capability can read connection-level config without re-declaring it.capability_options— the invoked capability's own option values (fromIntegration.capabilities[kind].options, layered with anyUSES-edge overrides). Empty when the capability declares none.assignment_options— the per-assignment (USES-edge) options.
The host also injects the running capability's bound Integration
(integration_slug) and the project's EXISTS_IN connections
(service_connections, each a ServiceConnection) so a capability can
read the canonical project↔Integration relationship without re-querying
the graph. A lifecycle capability maintains that relationship by setting
service_writeback; the host persists it as the EXISTS_IN edge against
the bound Integration.
resolve_user_by_identity is an optional host-injected coroutine that
maps an external identity subject (e.g. a provider's numeric user id)
to the matching Imbi user's email, or None when no active identity
matches. A capability uses it to attribute external actors — such as the
authors of synced commits — to Imbi users without knowing how the host
reaches the identity store: the gateway wires an HTTP /users/by-identity
lookup, the imbi-api worker a direct graph query. It is a live callable
rather than data, so it is excluded from serialization and is None on
any deserialized context. Callers should cache results.
PluginContext ¶
Bases: BaseModel
LinkWriteback ¶
Bases: BaseModel
A project-link URL the host should persist on the project node.
Set by a deployment / lifecycle capability on :class:PluginContext
when the call mutated, created, or discovered the canonical URL for
one of the project's external links. Covers four cases:
- Create -- a new repository was provisioned and the
github-repository(or equivalent) link must be stored for the first time. - Rename / 301 -- the remote answered a request to a renamed
repo with a
301to the canonical/repositories/{id}location; the host self-heals the stored link so later calls skip the redirect and the UI shows the current name. - Relocate / transfer -- the repository moved to a new owner
(e.g.
POST /repos/{owner}/{repo}/transfer) and the stored link must be rewritten to the new canonical URL. - Discovery -- the capability resolved a link the host had not yet stored (e.g. by following a redirect chain).
Capabilities only ever write this field; it is not part of the inbound context the host populates.
ServiceWriteback ¶
Bases: BaseModel
A project's Integration relationship the host should persist.
Set by a lifecycle capability on :class:PluginContext when a call
created, moved, or tore down the project's relationship with the
Integration the capability is bound to. The host persists it as the
(:Project)-[:EXISTS_IN]->(:Integration) edge -- writing
identifier and the canonical API URL -- and merges any
dashboard_links into Project.links.
The host owns the capability-to-Integration binding: the writeback
targets the Integration the capability is attached to (surfaced as
:attr:PluginContext.integration_slug), so it carries no slug of its
own and a capability cannot write an edge to an arbitrary Integration.
Capabilities only ever write this field; it is None on every
inbound context.
ServiceConnection ¶
Bases: BaseModel
A project's EXISTS_IN edge to an Integration.
Populated by the host on the inbound :class:PluginContext so
capabilities can read the canonical relationship a project has with
an Integration without re-querying the graph. Each connection
mirrors one (:Project)-[:EXISTS_IN]->(:Integration) edge.
Capabilities only ever read these; the canonical relationship is
maintained through :class:ServiceWriteback.
Credentials¶
There is exactly one credential store per Integration —
Integration.encrypted_credentials, a mapping of field name to a
Fernet-encrypted value. Decrypt it with
decrypt_integration_credentials, which accepts either the Integration
node (anything with an encrypted_credentials mapping) or the mapping
itself and returns the decrypted dict. There is no sibling lookup and no
fallback ordering: every capability of an Integration receives the same
decrypted blob (the credentials argument on every contract method).
from imbi.common.plugins import decrypt_integration_credentials
credentials = decrypt_integration_credentials(integration)
decrypt_integration_credentials ¶
decrypt_integration_credentials(
source: _HasEncryptedCredentials | dict[str, str],
) -> dict[str, str]
Decrypt an Integration's credential blob.
Accepts either an Integration node (anything with an
encrypted_credentials mapping) or the mapping itself. Each value
is Fernet-decrypted via :class:TokenEncryption; entries whose
ciphertext is empty or fails to decrypt are dropped rather than
surfaced as an empty string a key in dict check would satisfy.
Source code in libraries/common/src/imbi/common/plugins/credentials.py
Search Templates¶
For capabilities that build provider-specific query strings from project
context, use expand_template. It substitutes only the whitelisted
variables — project_slug, org_slug, team_slug, environment,
project_id, project_type_slug — written as ${name}, and rejects
anything else:
from imbi.common.plugins import expand_template
label_query = expand_template(
template, # e.g. '{app="${project_slug}", env="${environment}"}'
{
'project_slug': ctx.project_slug,
'org_slug': ctx.org_slug,
'team_slug': ctx.team_slug,
'environment': ctx.environment,
'project_id': ctx.project_id,
'project_type_slug': ctx.project_type_slugs[0],
},
)
Validate templates at assignment time with validate_template so
configuration errors surface early instead of during a search.
validate_template ¶
Validate a search template string, rejecting unknown variables.
Raises:
| Type | Description |
|---|---|
ValueError
|
If the template references variables not in the whitelist. |
Source code in libraries/common/src/imbi/common/plugins/templates.py
expand_template ¶
Expand a search template substituting whitelisted variables.
Absent variable values are replaced with empty strings. The template is
validated first to reject unknown placeholders, preserving the whitelist
guarantee from :func:validate_template.
Source code in libraries/common/src/imbi/common/plugins/templates.py
Registry and Loading¶
The host calls load_plugins() at startup (and reload_plugins() on
demand) to populate the registry from the convention scan unioned with
IMBI_PLUGINS. Every discovered plugin is validated fail-loud at load:
the class must subclass Plugin; its manifest must be a
PluginManifest with a supported api_version; every capability's
handler must subclass the contract for its kind; webhook-actions and
tools catalogs must enumerate cleanly with unique names; declared
vertex/edge labels must not collide; and plugin slugs must be unique.
Lookups are by plugin slug — get_plugin(slug) returns a
RegistryEntry (plugin_cls, manifest, package_name,
package_version) — and by (slug, kind) —
get_capability(slug, kind) returns the handler class for that
capability. list_plugins() returns every registered entry.
load_plugins() / reload_plugins() return a LoadResult
(loaded, errors, skipped).
load_plugins ¶
Discover, validate, and load all installed imbi plugins.
Source code in libraries/common/src/imbi/common/plugins/registry.py
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | |
reload_plugins ¶
get_plugin ¶
Get a registry entry by plugin slug.
Raises:
| Type | Description |
|---|---|
PluginNotFoundError
|
If the slug is not registered. |
Source code in libraries/common/src/imbi/common/plugins/registry.py
get_capability ¶
Return the handler class for kind on plugin slug.
Raises:
| Type | Description |
|---|---|
PluginNotFoundError
|
If the slug is not registered or the plugin
declares no capability of |
Source code in libraries/common/src/imbi/common/plugins/registry.py
list_plugins ¶
RegistryEntry
dataclass
¶
RegistryEntry(
plugin_cls: type[Plugin],
manifest: PluginManifest,
package_name: str,
package_version: str,
)
LoadResult ¶
Bases: NamedTuple
Plugin-declared Graph Schema¶
A plugin may extend the AGE graph with its own vertex labels and edges
via PluginManifest.vertex_labels / edge_labels. The host refuses any
declaration that collides with core schemata or with another plugin's
differently-shaped label (validate_no_collisions) and creates the
declared vlabels and indexes idempotently on startup
(apply_plugin_schemas).
validate_no_collisions ¶
validate_no_collisions(
manifests: list[PluginManifest],
schemata_toml_path: Path | None = None,
) -> None
Refuse vlabel/elabel name collisions across plugins / core.
Logs at ERROR and raises :class:PluginSchemaCollisionError on the
first collision detected.
Source code in libraries/common/src/imbi/common/plugins/schemas.py
apply_plugin_schemas
async
¶
Apply each plugin's declared vlabels + indexes to AGE.
Mirrors :func:imbi.common.graph.initializer.initialize primitives.
Idempotent — safe to invoke on every startup.
Source code in libraries/common/src/imbi/common/plugins/schemas.py
Errors¶
Capabilities should raise exceptions from imbi.common.plugins.errors
when the failure mode maps onto one of them; otherwise let the host wrap
the exception. The author-relevant ones:
| Exception | When to raise |
|---|---|
PluginCredentialsMissing |
A required credential is absent or empty in the credentials dict. |
PluginAuthenticationFailed |
The upstream rejected the call for an auth reason (HTTP 401, expired token); host may refresh and retry once. |
PluginRateLimited |
The upstream's rate limit is exhausted; carries retry_at so the host can pause and keep the job queued. |
PluginTimeoutError |
An upstream call exceeded the capability's internal timeout budget. |
PluginUnavailableError |
The upstream service is reachable but cannot serve the request (degraded, locked). |
CursorExpiredError |
A logs / incidents cursor is no longer valid and the caller must restart paging. |
IdentityAuthorizationPending |
An identity device-code flow has not completed yet; the host's poll loop retries. |
IdentityAuthorizationExpired |
An identity device code expired before the user completed it; the UI must restart the flow. |
PluginRemediationNotSupported |
Raised by the default AnalysisCapability.remediate when a capability emits no remediations. |
PluginSchemaCollisionError |
Raised by the host when a plugin's declared vlabel collides with core or another plugin. |
PluginNotFoundError is host-side (registry lookups) — capability code
should not raise it.
PluginCredentialsMissing ¶
Bases: Exception
Raised when required credentials are absent for a plugin.
PluginAuthenticationFailed ¶
Bases: Exception
Raised when a plugin's API call is rejected by the upstream IdP
or service for an authentication-related reason (HTTP 401, an AWS
ExpiredToken JSON-1.1 error, etc.).
Distinct from :class:PluginCredentialsMissing (which signals a
config-time absence) and :class:PluginUnavailableError (which
signals an upstream outage): this error tells the host's retry
layer that refreshing the actor's :class:IdentityConnection and
retrying the call once is a reasonable next step.
PluginRateLimited ¶
Bases: Exception
Raised when a plugin exhausts an upstream API's rate limit.
Carries retry_at -- a Unix epoch (time.time()-comparable) at
which the upstream says work may resume -- so the host can pause and
keep the job queued rather than fail it. Distinct from
:class:PluginAuthenticationFailed (refresh-and-retry) and
:class:PluginUnavailableError (upstream outage): this error tells
the host's queue layer to back off until retry_at and try again,
not to dead-letter the work.
Source code in libraries/common/src/imbi/common/plugins/errors.py
PluginTimeoutError ¶
Bases: Exception
Raised when a plugin call exceeds the configured timeout.
PluginUnavailableError ¶
Bases: Exception
Raised when a plugin slug exists in the graph but not the registry.
CursorExpiredError ¶
Bases: Exception
Raised by log plugins when a pagination cursor has expired.
IdentityAuthorizationPending ¶
Bases: Exception
Raised by an identity plugin's exchange_code while the user
has not yet completed an out-of-band authorization step (e.g. an
OAuth 2.0 device-code flow). The host's poll loop is expected to
catch this and retry at the plugin's polling interval.
IdentityAuthorizationExpired ¶
Bases: Exception
Raised by an identity plugin's exchange_code when an
out-of-band authorization (e.g. an IdP-issued device code) has
expired before the user completed it. The host should surface
this to the UI so the user can restart the flow.
PluginRemediationNotSupported ¶
Bases: Exception
Raised when a plugin is asked to remediate but does not implement
:meth:~imbi.common.plugins.base.AnalysisPlugin.remediate.
The host should treat this as a client error (the finding offered no fix, or the plugin advertised one without implementing it).
Source code in libraries/common/src/imbi/common/plugins/errors.py
PluginSchemaCollisionError ¶
Bases: Exception
Raised when a plugin declares a vlabel that collides with another plugin or with core's static schemata.
PluginNotFoundError ¶
Bases: Exception
Raised when a plugin slug is not registered.
Lifecycle and State¶
A new handler instance is created per request. Do not stash
connection state, cached credentials, or per-project data on self. If
you need a connection pool or rate-limited HTTP client, construct it
inside the method, scoped to the call:
async def list_keys(self, ctx, credentials):
async with httpx.AsyncClient(timeout=10.0) as client:
...
Testing¶
Capabilities can be unit-tested in isolation: instantiate the handler
class directly and call its methods with synthesized PluginContext
objects and a credentials dict. For registry-level integration tests,
see tests/test_plugins/test_registry.py in this repository for examples
of injecting a plugin without installing a real distribution.