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 1. 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()), send (send()) |
none |
embedder |
oaken.embedders |
none | model_id, dimensions, embed |
llm_backend |
oaken.llm_backends |
none | model_id, context_window, generate |
enricher |
oaken.enrichers |
none | version, needs_llm, enrich |
vector_store |
oaken.vector_stores |
none | upsert, delete, query, stats |
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 |
PluginContext
Handed to start(). config is the plugin's own [plugins.<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 |
_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 |
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' |
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 |
LLMResponse
| Field | Type | Default |
|---|---|---|
text |
str |
'' |
data |
dict[str, Any] | None |
None |
input_tokens |
int |
0 |
output_tokens |
int |
0 |
model_id |
str |
'' |
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 |
EnrichInput
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 |
EnrichOutput
A JSON-serializable result, which must match the enricher's output_schema.
| Field | Type | Default |
|---|---|---|
data |
dict[str, Any] |
required |
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 |
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) -> Any
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, 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 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) -> LLMResponse
Answer request. With a json_schema, data carries the parsed result.
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: EnrichInput) -> EnrichOutput
Derive this enricher's result for msg.
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.
Base classes
Optional. They supply defaults so a plugin overrides only what it does.
BaseProvider
Override pull (a mail source) or send (a sender) at least. Add authenticate and set_flags 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] = False
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 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) -> LLMResponse
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'version: ClassVar[str] = '1'needs_llm: bool = Falseoutput_model: ClassVar[type[BaseModel] | None] = None
Override:
async def enrich(self, msg: EnrichInput) -> EnrichOutput
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
The @enricher decorator
def enricher(name: str, *, version: str='1', needs_llm: bool=False, output: type[BaseModel] | None=None) -> Callable[[Callable[[EnrichInput], 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: EnrichInput) -> Result: return Result(words=len(msg.body.unwrap().split())) The function may return the model, a dict or an EnrichOutput; the host coerces it.