OAKENMAIL
Generated

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.

1

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
2

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
3

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:

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:

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:

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:

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:

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.

4

Base classes

Optional. They supply defaults so a plugin overrides only what it does.

BasePlugin

Set:

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:

Override:

BaseEmbedder

Set:

Override:

BaseLLMBackend

Set:

Override:

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:

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:

Override:

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:

Override:

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:

Override:

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:

Override:

BaseVectorStore

Set:

Override:

BaseCache

The LLM answer cache. Override all five members; key may return any tuple of strings.

Set:

Override:

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.