Plugin contracts
The signatures, data types and capabilities of the five plugin kinds, generated from the code.
This page is generated from src/oaken/plugins/contracts.py and
src/oaken/plugins/base.py, so it always matches the code. For a walkthrough, see
Writing a plugin.
The plugin API version is 2. A plugin whose api_version differs is rejected at load.
Kinds, entry points and capabilities
| Kind | Entry-point group | Capabilities (and the method each needs) | Required members |
|---|---|---|---|
provider |
oaken.providers |
auth (authenticate()), sync.pull (pull()), flags.write (set_flags()), attachments.detect (has_attachments()), send (send()) |
none |
embedder |
oaken.embedders |
none | model_id, dimensions, embed |
llm_backend |
oaken.llm_backends |
none | model_id, context_window, generate |
triager |
oaken.triagers |
none | version, needs_llm, rank, triage |
enricher |
oaken.enrichers |
none | version, needs_llm, enrich |
action |
oaken.actions |
none | version, needs_llm, routes, act |
vector_store |
oaken.vector_stores |
none | upsert, delete, query, stats |
cache_store |
oaken.cache_stores |
none | key, get, put, stats, clear |
Data types
Frozen dataclasses of plain data. These cross the boundary.
PluginInfo
What the host knows about a loaded plugin.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
version |
str |
required |
kind |
PluginKind |
required |
capabilities |
frozenset[str] |
required |
api_version |
int |
required |
PluginTable
A table a plugin owns. Core creates <kind>_<name>_<table> in its database and adds the columns below to the three every table has: uid (uuid7), created_at, updated_at. A column type is one of text, int, real, bool or json. A reference to a message, a rule or another record is a text column holding a UID: never a foreign key across an owner.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
columns |
Mapping[str, str] |
required |
AttachmentRef
One attachment of a message, as an action sees it: where it sits and what it is called. quarantine says core flagged it as risky by its name or type. scan is what a scanner has said of the quarantined file (pending, clean, infected or error), or empty for an attachment still inline in older mail.
| Field | Type | Default |
|---|---|---|
part_path |
str |
required |
filename |
str | None |
required |
content_type |
str |
required |
size |
int |
required |
quarantine |
bool |
required |
scan |
str |
'' |
Verdict
What a scanner says of one file (CORE_57): clean, infected or error, with a detail string (the signature name for infected, the reason for error). On any doubt a scanner says error, never clean: only an explicit OK from the engine is clean, because a clean is the one verdict that releases the file.
| Field | Type | Default |
|---|---|---|
status |
ScanStatus |
required |
detail |
str |
'' |
PluginContext
Handed to start(). config is the plugin's own [plugins.<kind>.<name>] table.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
kind |
PluginKind |
required |
settings |
Settings |
required |
clock |
Clock |
required |
log |
Any |
required |
config |
dict[str, Any] |
empty |
bus |
EventBus | None |
None |
account_id |
str | None |
None |
llm |
LLMAccess | None |
None |
trace |
Callable[..., None] |
_no_trace |
tracing |
Callable[[], bool] |
_not_tracing |
store |
PluginStore | None |
None |
folders |
Mapping[str, Path] |
empty |
draft |
PluginDraft | None |
None |
attachments |
PluginAttachments | None |
None |
_subscriptions |
list[Callable[[], None]] |
empty |
Health
| Field | Type | Default |
|---|---|---|
ok |
bool |
required |
detail |
str |
'' |
PluginOpContext
What an operation handler of a plugin receives besides its input. No database handle: a plugin reaches its own service, its config and the clock, nothing else.
| Field | Type | Default |
|---|---|---|
plugin |
str |
required |
client |
str |
required |
actor |
str |
required |
config |
dict[str, Any] |
required |
clock |
Clock |
required |
log |
Any |
required |
store |
PluginStore | None |
None |
folders |
Mapping[str, Path] |
empty |
draft |
PluginDraft | None |
None |
PluginChange
One line of a proposal: what would change. target is an id, never mail text.
| Field | Type | Default |
|---|---|---|
target |
str |
required |
field |
str |
'' |
before |
Any |
None |
after |
Any |
None |
PluginPreview
What a mutating operation would do, shown to the person before it does it.
| Field | Type | Default |
|---|---|---|
summary |
str |
required |
changes |
tuple[PluginChange, ...] |
() |
total |
int |
0 |
PluginOperation
One operation a plugin offers. permission is read or mutate (a plugin cannot send). A mutate is confirmed by the person at the CLI or UI, and by proposal and token over MCP; give it a preview to say what it would change. mcp is off (the user may turn it on) or never (they may not): a plugin's operations are never on by default. input and output are pydantic models.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
summary |
str |
required |
permission |
Literal['read', 'mutate'] |
required |
input |
type[BaseModel] |
required |
output |
type[BaseModel] |
required |
handler |
PluginOpHandler |
required |
preview |
PluginOpPreview | None |
None |
mcp |
Literal['off', 'never'] |
'off' |
per_account |
bool |
False |
UiField
One input of a page. kind is text, number, bool, enum (choices) or list (one string a line). name is the key in the page's load values and in what test and submit receive.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
label |
str |
'' |
kind |
Literal['text', 'number', 'bool', 'enum', 'list'] |
'text' |
choices |
tuple[str, ...] |
() |
help |
str |
'' |
required |
bool |
False |
UiButton
A button on a plugin's settings modal: runs one of the plugin's own operations, which takes no input.
| Field | Type | Default |
|---|---|---|
label |
str |
required |
operation |
str |
required |
confirm |
str |
'' |
UiSection
A titled group of the plugin's config fields, in the settings modal.
| Field | Type | Default |
|---|---|---|
title |
str |
required |
fields |
tuple[str, ...] |
required |
UiPage
A page core serves at /plugins/<plugin>/<name> and renders as a form. load is a read operation of the plugin with no input; it returns {"values": {field: value}, "messages": [str], "rows": [{"id", "label"}]} (each part optional). test (read) and submit (the plugin's own, a mutate is confirmed) take the typed fields as input. With order set, the page lists rows in order, each with a position box (1 to n), and Update calls the operation order with {"order": [row ids]}: a row whose number changed goes there and the others shift to make room (ADR 0018, 4a).
| Field | Type | Default |
|---|---|---|
name |
str |
required |
title |
str |
required |
load |
str |
required |
submit |
str |
'' |
fields |
tuple[UiField, ...] |
() |
test |
str |
'' |
confirm |
str |
'' |
order |
str |
'' |
UiSpec
| Field | Type | Default |
|---|---|---|
sections |
tuple[UiSection, ...] |
() |
buttons |
tuple[UiButton, ...] |
() |
pages |
tuple[UiPage, ...] |
() |
PluginJobContext
What a plugin's job handler receives besides its payload. Like PluginOpContext: the plugin's own config, the clock and a logger, and the registered kind it is running — no database handle, no queue, no job internals.
| Field | Type | Default |
|---|---|---|
plugin |
str |
required |
kind |
str |
required |
config |
dict[str, Any] |
required |
clock |
Clock |
required |
log |
Any |
required |
PluginJobHandler
One job kind a plugin offers. kind is the plugin's local name (recount); core namespaces it to plugin.<name>.<kind>. payload_model and result_model are pydantic models: the payload is validated before the handler runs, and a result (if result_model is set) is validated and stored, exactly as for a core job kind. concurrency caps how many run at once ([jobs] kind_limits still wins). A plugin enqueues its kind with ctx helpers added later; for now core and other plugins can enqueue it by its namespaced name.
| Field | Type | Default |
|---|---|---|
kind |
str |
required |
payload_model |
type[BaseModel] |
required |
handler |
PluginJobHandlerFn |
required |
concurrency |
int | None |
None |
result_model |
type[BaseModel] | None |
None |
PluginStageContext
What a plugin's pipeline stage receives besides its input: the plugin's own config, the clock and a logger. No database handle, no queue.
| Field | Type | Default |
|---|---|---|
plugin |
str |
required |
stage |
str |
required |
config |
dict[str, Any] |
required |
clock |
Clock |
required |
log |
Any |
required |
PluginStageInput
The message a stage runs on, after its enrichers. Everything derived from the mail is UntrustedText. enrichments is the message's stored enrichment results by enricher name (summary, actions, ...) and triage its stored triage verdict (None when unrated), so a stage can roll up or tag from them without re-reading the body.
| Field | Type | Default |
|---|---|---|
message_id |
str |
required |
account_id |
str |
required |
subject |
UntrustedText |
required |
body |
UntrustedText |
required |
enrichments |
dict[str, dict[str, Any]] |
empty |
triage |
dict[str, Any] | None |
None |
PluginStage
One pipeline stage a plugin offers, run after the enrichers for each message. name is the plugin's local stage name; core stores its result under plugin.<plugin>.<name>. version is part of the idempotency key: bump it when the output could change, and a rerun recomputes; leave it and a rerun is a cache hit.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
handler |
PluginStageFn |
required |
version |
str |
'1' |
PluginScheduledTask
One recurring task a plugin offers. Give exactly one of every (seconds) or cron (a five-field expression). job_kind is the kind enqueued when it fires — commonly the plugin's own plugin.<name>.<kind> — and payload is the fixed input for that job.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
job_kind |
str |
required |
payload |
BaseModel |
required |
every |
float | None |
None |
cron |
str | None |
None |
RawMessage
One message exactly as the server holds it.
| Field | Type | Default |
|---|---|---|
provider_id |
str |
required |
raw_bytes |
bytes |
required |
provider_labels |
tuple[str, ...] |
() |
provider_flags |
frozenset[str] |
frozenset() |
internal_date |
datetime | None |
None |
FlagChange
A flag change made on the server side (for example, read on another device).
| Field | Type | Default |
|---|---|---|
provider_id |
str |
required |
add |
frozenset[str] |
frozenset() |
remove |
frozenset[str] |
frozenset() |
PullBatch
One page of changes. next_cursor is opaque: core stores it and hands it back.
| Field | Type | Default |
|---|---|---|
messages |
tuple[RawMessage, ...] |
() |
deleted_provider_ids |
tuple[str, ...] |
() |
flag_changes |
tuple[FlagChange, ...] |
() |
next_cursor |
str | None |
None |
LLMRequest
system is a static instruction. Mail content only ever appears in user, as UntrustedText (plain str parts are for the application's own words).
| Field | Type | Default |
|---|---|---|
system |
str |
required |
user |
tuple[str | UntrustedText, ...] |
() |
json_schema |
dict[str, Any] | None |
None |
max_output_tokens |
int | None |
None |
temperature |
float |
0.0 |
think |
bool | None |
None |
LLMResponse
| Field | Type | Default |
|---|---|---|
text |
str |
'' |
data |
dict[str, Any] | None |
None |
input_tokens |
int |
0 |
output_tokens |
int |
0 |
model_id |
str |
'' |
thinking |
str |
'' |
meta |
dict[str, Any] |
empty |
EnrichAttachment
What the message says about one attachment. The bytes are never passed to an enricher.
| Field | Type | Default |
|---|---|---|
filename |
UntrustedText | None |
required |
content_type |
str |
required |
size |
int |
required |
inline |
bool |
False |
quarantine |
bool |
False |
CleanMessage
A cleaned message. Everything derived from the mail is UntrustedText. headers holds a few display values (from). raw_headers has every header, keys in lower case and every value kept, for a deterministic enricher such as the inspector. parent_summary is the stored summary of the message this one replies to, when core has one: a reply is summarized from its own new text plus that, never from the parent's body.
| Field | Type | Default |
|---|---|---|
message_id |
str |
required |
account_id |
str |
required |
subject |
UntrustedText |
required |
body |
UntrustedText |
required |
headers |
dict[str, UntrustedText] |
empty |
raw_headers |
dict[str, tuple[UntrustedText, ...]] |
empty |
attachments |
tuple[EnrichAttachment, ...] |
() |
date |
datetime | None |
None |
is_reply |
bool |
False |
parent_summary |
UntrustedText | None |
None |
labels |
dict[str, tuple[str, ...]] |
empty |
filepath |
str | None |
None |
EnrichOutput
A JSON-serializable result, which must match the enricher's output_schema.
| Field | Type | Default |
|---|---|---|
data |
dict[str, Any] |
required |
TriageVerdict
A triager's verdict: a JSON-serializable dict that must match the triager's output_schema. It is not an enricher's result, and is stored apart from one.
| Field | Type | Default |
|---|---|---|
data |
dict[str, Any] |
required |
ActionResult
What an action did, as a JSON-serializable dict that must match its output_schema. It holds no mail text. Success is returning one; failure is raising.
| Field | Type | Default |
|---|---|---|
data |
dict[str, Any] |
empty |
VectorItem
One vector. The model and dimensions are recorded per vector; a store never mixes them.
| Field | Type | Default |
|---|---|---|
message_id |
str |
required |
chunk_id |
str |
required |
vector |
tuple[float, ...] |
required |
model_id |
str |
required |
content_hash |
str |
required |
account_id |
str | None |
None |
Hit
| Field | Type | Default |
|---|---|---|
message_id |
str |
required |
chunk_id |
str |
required |
score |
float |
required |
model_id |
str |
'' |
VectorStats
| Field | Type | Default |
|---|---|---|
count |
int |
required |
model_id |
str | None |
None |
dimensions |
int | None |
None |
CacheEntry
A validated model answer and what it cost to get, as the LLM answer cache stores it.
| Field | Type | Default |
|---|---|---|
result_json |
str |
required |
tokens_in |
int |
required |
tokens_out |
int |
required |
CacheStats
| Field | Type | Default |
|---|---|---|
entries |
int |
required |
bytes |
int |
0 |
by_task |
dict[str, int] |
empty |
prompt_versions |
tuple[str, ...] |
() |
oldest |
datetime | None |
None |
Protocols
A plugin satisfies a contract by having these members.
LLMAccess
The one way a plugin reaches a language model: a PromptBuilder task about mail, as UntrustedText parts. Core picks the workspace from the account, enforces what it allows, answers a repeated question from its cache and validates the answer against the task's schema. A plugin never sees a backend, a database or a workspace.
async def generate_cached(self, builder: 'PromptBuilder[Any]', *parts: UntrustedText, content_hash: str, account_id: str | None=None, message_id: str | None=None, model: str | None=None) -> Any
PluginStore
A plugin's own tables, by name. Declarative on purpose: no SQL, no other plugin's data.
async def insert(self, table: str, row: Mapping[str, Any]) -> dict[str, Any]
async def get(self, table: str, uid: str) -> dict[str, Any] | None
async def find(self, table: str, where: Mapping[str, Any] | None=None, *, order_by: str='created_at', limit: int=1000) -> list[dict[str, Any]]
async def update(self, table: str, uid: str, fields: Mapping[str, Any]) -> dict[str, Any]
async def delete(self, table: str, uid: str) -> bool
PluginAttachments
An action's door to attachments (ADR 0019). Only action plugins have it. Attachments live in core's quarantine, mode 0000, not in the database. Two things can be done with one: release it (copy_to, which needs a clean scan) and, on the scan route only, open it for a scan (scanning) and record the verdict (record_scan).
async def list(self, message_id: str) -> list[AttachmentRef]
async def copy_to(self, message_id: str, part_path: str, out: BinaryIO, *, max_bytes: int=0) -> int | None
Release the decoded attachment into out; it is never held in memory. Returns the bytes written, or None when over max_bytes (0: no limit). Refused unless its scan is clean.
def scanning(self, message_id: str, part_path: str) -> AbstractAsyncContextManager[Path]
The quarantined file's path, readable only inside the block (async with), then locked again. Only while the running job is the scan route.
async def record_scan(self, message_id: str, part_path: str, status: str, detail: str='', scanner: str='') -> None
Record what a scan found: clean, infected or error. scan route only.
PluginDraft
The staged copy of the plugin's own config.toml table. The plugin reads and replaces it; core validates it against config_model, writes the file once with one backup, and reloads the plugin.
async def get(self) -> dict[str, Any] | None
async def set(self, values: Mapping[str, Any]) -> None
async def discard(self) -> None
async def apply(self) -> None
async def drift(self) -> bool
Plugin
Lifecycle, shared by every kind. All three are optional for a plugin to define.
async def start(self, ctx: PluginContext) -> None
Acquire resources. Raising marks the plugin unhealthy; core carries on.
async def stop(self) -> None
Release resources. Called once at shutdown.
async def health(self) -> Health
A cheap liveness check, polled periodically by the host.
Provider
A mail source or a way to send. Capabilities: auth, sync.pull, flags.write, attachments.detect, send. A provider declares at least sync.pull or send: a send-only provider (an SMTP server) has no mail to pull.
async def authenticate(self, ctx: PluginContext) -> None
Make sure the plugin can talk to the service (capability auth).
def pull(self, cursor: str | None, folder: str | None=None) -> AsyncIterator[PullBatch]
Yield batches of changes since cursor (None: from the start). Capability sync.pull. Re-pulling from the last cursor returns nothing new. folder limits the pull to one folder, for a provider with folders (set supports_folders = True); the cursor is still the account's whole cursor, and a folder pull only moves that folder's part of it. Core passes folder only to a provider that supports it, and only when the user scheduled that folder on its own.
async def set_flags(self, provider_ids: Sequence[str], add: Collection[str], remove: Collection[str]) -> None
Write flags back to the server (capability flags.write).
async def has_attachments(self, provider_ids: Sequence[str]) -> dict[str, bool]
Whether each message has an attachment, asked of the service's own view of the message structure and never by downloading the message (capability attachments.detect). An id the service cannot answer for (gone, wrong folder) is left out, not guessed at.
async def send(self, raw_bytes: bytes) -> str
Send a message and return its provider id. Declared for the future; unimplemented in v1, and the host never routes sending to a plugin (capability send).
Embedder
Turns text into vectors.
Attributes:
model_id: strdimensions: intmax_input_tokens: int
async def embed(self, texts: Sequence[str]) -> list[list[float]]
One vector of dimensions floats per text, in order.
LLMBackend
Runs a language model.
Attributes:
model_id: strcontext_window: int
async def generate(self, request: LLMRequest, *, model: str | None=None) -> LLMResponse
Answer request. With a json_schema, data carries the parsed result. model names which of the backend's models to run; None means the started default (model_id). The response's model_id is the model that actually ran.
async def available_models(self) -> list[str]
The model ids this backend can run right now, for a picker to offer.
Enricher
Derives something from one message. version is part of the cache key: bump it when the output could change.
Attributes:
version: strneeds_llm: booloutput_schema: dict[str, Any]
async def enrich(self, msg: CleanMessage) -> EnrichOutput
Derive this enricher's result for msg.
Triager
The first step of the pipeline: rates one message so core can decide what else it is owed. Triagers run in rank order (lowest first) and the first verdict wins. version is part of the cache key: bump it when the output could change.
Attributes:
version: strneeds_llm: boolrank: intoutput_schema: dict[str, Any]
async def triage(self, msg: CleanMessage) -> TriageVerdict | None
The verdict for msg, or None to leave it to the next triager.
Action
Does something about a message triage routed to it (ADR 0019): save an attachment, open a ticket, fill in a report. routes are the route names it answers to. version is part of the result's identity: bump it when the output could change. An action should be idempotent, because a retry after a crash can run it again for the same message and route.
Attributes:
version: strneeds_llm: boolroutes: tuple[str, ...]output_schema: dict[str, Any]
async def act(self, msg: CleanMessage, route: str) -> ActionResult | None
Do the work for msg, routed here as route; None means there was nothing to do.
VectorStore
Stores vectors and finds the nearest ones.
async def upsert(self, items: Sequence[VectorItem]) -> None
Insert or replace by (message_id, chunk_id). Raises if an item's model or dimensions differ from what the store already holds, leaving the store unchanged.
async def delete(self, message_ids: Sequence[str]) -> None
Remove every vector of these messages.
async def query(self, vector: Sequence[float], k: int, filters: dict[str, str] | None=None) -> list[Hit]
The k nearest, best first. filters may name account_id and model_id; a store that sits beside the index may take more (thread_key, after, before, sender) and must refuse a filter it does not know rather than ignore it. Raises if vector does not match the stored dimensions.
async def stats(self) -> VectorStats
How many vectors, and which model and dimensions they use.
CacheOps
The LLM answer cache's members. Core's built-in SQLite cache has them too (it is not a plugin). Core hands over the facts of a question; the cache owns the key.
def key(self, *, workspace: str, task: str, content_hash: str, model: str, prompt_version: str) -> tuple[str, ...]
The key for this question. model is the model that will answer it, never a backend's default: two models must not share a key.
async def get(self, key: tuple[str, ...]) -> CacheEntry | None
The stored answer, or None. A miss costs one model call; raising counts as a miss.
async def put(self, key: tuple[str, ...], entry: CacheEntry) -> None
Store entry. Raising loses the cache write, never the answer.
async def stats(self, workspace: str | None=None) -> CacheStats
What the cache holds (one workspace's, or all).
async def clear(self, workspace: str | None=None, task: str | None=None) -> int
Forget answers (one workspace's and/or one task's, or all). How many were removed.
Cache
An LLM answer cache plugin.
Base classes
Optional. They supply defaults so a plugin overrides only what it does.
BasePlugin
Set:
kind: ClassVar[PluginKind]api_version: ClassVar[int] = PLUGIN_API_VERSIONversion: ClassVar[str] = ''setup_help: ClassVar[str] = ''config_example: ClassVar[str] = ''config_model: ClassVar[type[BaseModel] | None] = Noneenabled_default: ClassVar[bool] = Truerequires_config: ClassVar[tuple[str, ...]] = ()tables: ClassVar[tuple[PluginTable, ...]] = ()folders: ClassVar[tuple[str, ...]] = ()ctx: PluginContextconfig: Any = None
BaseProvider
Override pull (a mail source) or send (a sender) at least. Add authenticate and set_flags and has_attachments as the service allows. Capabilities come from what you override (pull is sync.pull, set_flags is flags.write). To state them yourself, set capabilities = frozenset({...}); the host rejects a capability whose method you did not write.
Set:
kind: ClassVar[PluginKind] = 'provider'supports_folders: ClassVar[bool] = Falseaccount_defaults: ClassVar[dict[str, Any]] = {}
Override:
async def authenticate(self, ctx: PluginContext) -> Nonedef pull(self, cursor: str | None, folder: str | None=None) -> AsyncIterator[PullBatch]async def set_flags(self, provider_ids: Sequence[str], add: Collection[str], remove: Collection[str]) -> Noneasync def has_attachments(self, provider_ids: Sequence[str]) -> dict[str, bool]async def send(self, raw_bytes: bytes) -> str
BaseEmbedder
Set:
kind: ClassVar[PluginKind] = 'embedder'model_id: str = ''dimensions: int = 0max_input_tokens: int = 8192
Override:
async def embed(self, texts: Sequence[str]) -> list[list[float]]
BaseLLMBackend
Set:
kind: ClassVar[PluginKind] = 'llm_backend'model_id: str = ''context_window: int = 0locality: str = 'remote'
Override:
async def generate(self, request: LLMRequest, *, model: str | None=None) -> LLMResponse
BaseAnalyzer
What the plugins that read a message and return a result share (ADR 0017): an enricher, a triager and an action. It is a helper for their base classes, not a kind of its own: it has no entry-point group and nothing outside the plugin classes names it. A plugin extends exactly one of BaseEnricher, BaseTriager or BaseAction. version is part of the identity of a stored result: bump it when the output could change. needs_llm says whether it asks a model. output_model is the pydantic model its result must match; the host checks it and publishes its JSON schema.
Set:
version: ClassVar[str] = '1'needs_llm: bool = Falseoutput_model: ClassVar[type[BaseModel] | None] = None
BaseEnricher
Set version and, ideally, output_model (a pydantic model): the host then checks the result against it and publishes its JSON schema. Override enrich.
Set:
kind: ClassVar[PluginKind] = 'enricher'
Override:
async def enrich(self, msg: CleanMessage) -> EnrichOutput
BaseTriager
The first step of the pipeline: rate a message so core can decide what else it is owed. This is the deterministic kind: override triage to return the verdict (a TriageVerdict), or None to leave the message to the next triager. One that asks a model extends LLMTriager. Triagers run in rank order (lowest first) and the first verdict wins, so the model is asked only for what the cheaper ones leave. Set output_model as for an enricher.
Set:
kind: ClassVar[PluginKind] = 'triager'rank: ClassVar[int] = 50kind_tables: ClassVar[tuple[PluginTable, ...]] = (PluginTable('levels', {'value': 'int', 'name': 'text'}),)
Override:
async def triage(self, msg: CleanMessage) -> TriageVerdict | None
BaseAction
Does something about a message triage routed to it (ADR 0019). Set routes to the route names it answers to and override act. Return an ActionResult (or None when there was nothing to do); raise to fail. It should be idempotent: a retry after a crash can run it again for the same message and route. Whether it runs on its own or waits for the person is the reader's setting, approval = "ask" | "auto" in its table, and the default is ask.
Set:
kind: ClassVar[PluginKind] = 'action'routes: ClassVar[tuple[str, ...]] = ()auto_routes: ClassVar[tuple[str, ...]] = ()
Override:
async def act(self, msg: CleanMessage, route: str) -> ActionResult | None
BaseScanner
An attachment scanner: an action on the scan route (ADR 0020, CORE_57). The author overrides one method, scan(path) -> Verdict, and the base does the rest through an AttachmentHandler: list the message's attachments, open each one still owed a verdict (0400, only inside the block), call scan and record what it said. It is idempotent: a clean or infected file is left alone, so a rescan or a retry after a crash is cheap and safe. scan is an auto_route because it only reads a file and records a verdict (it releases nothing), so it is safe to run unasked; the reader can still set approval = "ask". A scanner must never say clean on doubt: an open that fails or a scan that raises is recorded as error for that one file, which keeps it held, and reprocess(stage="scan") retries it. The scanner's name in the recorded verdict is the plugin's name (ctx.name).
Set:
kind: ClassVar[PluginKind] = 'action'routes: ClassVar[tuple[str, ...]] = ('scan',)auto_routes: ClassVar[tuple[str, ...]] = ('scan',)output_model: ClassVar[type[BaseModel] | None] = ScanResult_handler: AttachmentHandler | None = None
Override:
async def scan(self, path: Path) -> Verdict
BaseVectorStore
Set:
kind: ClassVar[PluginKind] = 'vector_store'
Override:
async def upsert(self, items: Sequence[VectorItem]) -> Noneasync def delete(self, message_ids: Sequence[str]) -> Noneasync def query(self, vector: Sequence[float], k: int, filters: dict[str, str] | None=None) -> list[Hit]async def stats(self) -> VectorStats
BaseCache
The LLM answer cache. Override all five members; key may return any tuple of strings.
Set:
kind: ClassVar[PluginKind] = 'cache_store'
Override:
def key(self, *, workspace: str, task: str, content_hash: str, model: str, prompt_version: str) -> tuple[str, ...]async def get(self, key: tuple[str, ...]) -> CacheEntry | Noneasync def put(self, key: tuple[str, ...], entry: CacheEntry) -> Noneasync def stats(self, workspace: str | None=None) -> CacheStatsasync def clear(self, workspace: str | None=None, task: str | None=None) -> int
The @enricher decorator
def enricher(name: str, *, version: str='1', needs_llm: bool=False, output: type[BaseModel] | None=None) -> Callable[[Callable[[CleanMessage], Awaitable[object]]], type[BaseEnricher]]
Turn one async function into an enricher class, ready to register as an entry point: class Result(BaseModel): words: int @enricher("word-count", output=Result) async def word_count(msg: CleanMessage) -> Result: return Result(words=len(msg.body.unwrap().split())) The function may return the model, a dict or an EnrichOutput; the host coerces it.