OAKENMAIL
GeneratedPre-alpha

Software map

The components of Oaken Mail, the layers they sit in, what calls what, and which pieces exist yet.

1

Status

35 components in 9 layers: 33 built, 2 partial, 0 planned.

Status Meaning
Built Implemented and checked in the repository.
Partial Exists, but only part of what is planned.
Planned Designed; no code yet.

Generated from docs/softmap.toml by docs/tools/softmap.py, which refuses to build if the data breaks the layering rules or disagrees with the repository. Edit the data in the same change that builds or alters a component.

2

Map

Click a component to draw its relationships. Each separate piece of software is its own framed stack, with its layers top to bottom. The border shows status; a red dot marks components that handle untrusted input.

ClientsLocal APIOperations and servicesIngestRuntimePluginsStorageFoundationToolingThin clientsOaken daemon (one asyncio process)Project toolingCLIpartial · prompt 17MCP stdio bridgebuilt · prompt 18Web UIpartial · prompt laterAPI daemonbuilt · prompt 16Handles untrusted inputCore daemonbuilt · prompt 16MCP serverbuilt · prompt 18Handles untrusted inputOperation registrybuilt · prompt 13Retrieval and askbuilt · prompt 15Handles untrusted inputRead statebuilt · prompt 12Audit logbuilt · prompt 12Ingest pipelinebuilt · prompt 09Handles untrusted inputAttachment quarantinebuilt · prompt 09Handles untrusted inputParsingbuilt · prompt 06Handles untrusted inputEvent busbuilt · prompt 03Job queuebuilt · prompt 03Agents and the swarmbuilt · prompt 03bSchedulerbuilt · prompt 03Sync schedulebuilt · prompt 12bPlugin hostbuilt · prompt 04Providerbuilt · prompt 11Handles untrusted inputEmbedderbuilt · prompt 10Handles untrusted inputLLM backendbuilt · prompt 10Handles untrusted inputEnrichersbuilt · prompt 14Handles untrusted inputVector storebuilt · prompt 08raw.dbbuilt · prompt 05Sieve filteringbuilt · prompt 06cHandles untrusted inputindex.dbbuilt · prompt 07Handles untrusted inputCore foundationbuilt · prompt 02Prompt safetybuilt · prompt 10Ollama pluginbuilt · prompt 10Gmail pluginbuilt · prompt 11SMTP send pluginbuilt · prompt 11bIMAP providerbuilt · prompt 11cRepo and CIbuilt · prompt 01Docs sitebuilt · prompt 01a
calls (selected component calls this)called bynotifies (event or callback, upward)notified by
Select a component on the map.
3

Conventions

4

Components

Thin clients

Clients

Thin command-line client of the local API: daemon control, mail commands, the generic operation fallback and the event stream. Commands are lazy-loaded; most are one registry operation each.

Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Functions
SignatureWhat it does
main(argv: Sequence[str] | None = None) -> intParses the command line and runs one command; returns the exit code.
External none
Notes
  • Does not auto-start core. If the daemon is not running it says so and exits.
  • A separate package, oaken-cli (import oaken_cli), in the uv workspace under apps/cli (ADR 0006).
  • HttpApiClient speaks the loopback API; the same command tests also run against an in-process client.

oaken mcp: lets stdio-only MCP clients reach the in-core MCP server. Adds no behaviour of its own.

Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Functions
SignatureWhat it does
Bridge.handle(line) -> str | NoneForwards one JSON-RPC message to /mcp (or /mcp/agent) and returns core's answer, or a structured MCP error.
External none
Notes
  • Does not start core and never opens the databases; it imports no core code beyond discovery.
  • Core down: a tool call is an isError result {code: core_not_running} with the start hint, other requests a JSON-RPC error, and initialize is answered locally so a client that starts first still connects. It finds core again on every message.
  • --agent speaks to the swarm door with OAKEN_AGENT_TOKEN.
Web UIPartial prompt laterapps/webui/src/oaken_webui · map

Browser client in its own app (oaken-webui), parallel to oaken-cli: it serves the static UI and reverse-proxies /v1/* to the running core over loopback — the general API bridge, never MCP. Core stays headless (ADR 0007).

Called by
none
Notifies (event or callback, upward)
none
Notified by
none
External none
Notes
  • Holds the API token server-side and adds X-Oaken-Client: ui; the token never reaches the browser, which sees one same-origin server.
  • Serves the oakenmail-templates UI (demo marker stripped so it calls the API); the published demo under docs/ keeps the marker and its dummy fixtures.
  • Partial: the server, the proxy and the static serving are built and tested; the UI pages themselves are only partly wired, and core's vestigial static_dir mount is not yet removed.
  • Loopback only; does not start core. Core down -> 503 core_unavailable, like the CLI.

Oaken daemon (one asyncio process)

Local API

API daemonBuilt prompt 16 untrusted inputsrc/oaken/api · map

General local API for the CLI and web UI: HTTP and server-sent events on 127.0.0.1 with token auth. Routes are generated from the operation registry; a guard checks Host, Origin, content type, token, rate and size before any route runs.

Notifies (event or callback, upward)
none
Notified by
Event bus raw.db
Types
NameKindMeaning
ApiclassThe ASGI application: generated operation routes, events, the agent job routes, static files, and a mount point for more surfaces.
SurfacedataclassA path prefix with its own token check, size and concurrency limits and caller class. General, agent, and later MCP.
EventRingclassNumbers bus events and keeps the last N so Last-Event-ID can resume; says gap when it cannot.
RuntimeFileclassruntime.json: pid, port, version. Owner-only, and the single-instance lock.
External none
Notes
  • Binds to loopback only; configuration cannot say otherwise. The token file and runtime file are mode 0600.
  • uvicorn and Starlette, chosen in ADR 0005. The MCP doors mount here (prompt 18).
Core daemonBuilt prompt 16src/oaken/daemon · map

Opens and wires every store and service in order, serves the API, and stops cleanly on SIGINT or SIGTERM. Runs in the foreground or detached.

Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Functions
SignatureWhat it does
serve(core, runtime) -> NoneClaim the runtime file, start core, wait for a stop, shut down in reverse order, remove the file.
detach() -> dictStart core in the background and return its runtime file once it is listening.
stop() -> boolSignal the running core and wait for it to exit.
External none
Notes
  • Single instance: the runtime file is created exclusively before anything is opened.
  • A killed process leaves a stale file, replaced by the next start; its leases expire like any dead worker's.
MCP serverBuilt prompt 18 untrusted inputsrc/oaken/api/mcp_server.py · map

The AI-enabled surface of core's API and the heart of the product: a local, data-only MCP server for sensitive mail. Tools are generated from the operation registry; sending is never exposed.

Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
McpServerclassThe SDK's stateless streamable-HTTP server over a CoreClient, plus the Surface that guards it. One class for the person's door and the swarm door.
Functions
SignatureWhat it does
McpServer._call_toolCalls the registry as the caller class mcp; a mutation returns a proposal and needs the token; mail-derived results are fenced as untrusted; results are compact and bounded.
External none
Notes
  • Runs whether or not the web UI does. Its own token (mcp_token), caller class and limits (ADR 0004).
  • Mounted at /mcp on the loopback listener; [api] mcp = false removes it. A door is exactly its own path.
  • With the swarm on, a second door at /mcp/agent takes agent tokens and offers claim_job, heartbeat_job, complete_job and fail_job from an agent-only registry; the registry audits each claim, completion and failure with the agent named.

Operations and services

Typed operations with permission classes and the confirm gate. HTTP and MCP surfaces are generated from it.

Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
OperationdataclassName, permission (read, mutate, send), input and output models, MCP ceiling, preview and handler.
CoreClientclassThe one path every call takes: exposure, input, class, confirm gate, handler, audit.
ProposalmodelWhat a mutation would change, with a single-use token an agent redeems.
External none
Notes
  • Anything that mutates mail is gated here, in code, not in a prompt.
  • 34 core operations, plus those plugins offer as <plugin>.<name>. Retrieval (search, ask) is built (prompt 15).
  • The HTTP surface is the API component. Exposure to MCP is a ceiling in code and a user setting in operation_settings.
Retrieval and askBuilt prompt 15 untrusted inputsrc/oaken/core/search · map

Hybrid keyword and vector search merged by rank fusion, a token-budgeted context, and question answering whose every claim must quote the message it cites.

Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
HybridSearchclassBM25 and vector search in parallel, filtered, collapsed to messages and merged with RRF; the vector side degrades, never raises.
ContextBuilderclassFills ai.ask_context_tokens in tiers (headers and summaries, matched chunks, neighbours and thread), dedupes, and reports tokens saved.
Retrievalclasssearch, evidence and ask over the pipeline's index, vectors and embedder.
AskResultfrozen dataclassAnswer, verified citations, confidence, insufficient_evidence, degraded and token figures.
Functions
SignatureWhat it does
verify(quote, text)The quote as it stands in the cited chunk, or None when it is not there (whitespace-tolerant); a fabricated quote is dropped.
python -m oaken.evals retrievalSeeded synthetic mailbox; Recall@k, MRR, nDCG@10, citation precision and cost; writes docs/evals/latest.json.
External none
Notes
  • Built means: tested with the fake embedder and model, including a vector outage, hostile queries, fabricated quotes and the budget as a property test. Offline numbers use stand-ins on a synthetic corpus built so each retriever has a weakness; a live run is manual.
  • ask refuses mail from more than one account in a single prompt, and is off over MCP until the user turns it on.
  • Nothing attaches Services.retrieval or a model service to a running process until the daemon (prompt 16).

Marks mail read only when the user opens it or says to (a summary alone never does). Local first: sets the state and a dirty marker, audits, then writes back through a durable job. A change nothing can write stays local and says so.

Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
ReadStateclassmark_read, mark_unread, message_presented, message_dismissed, undo, resume_pending and apply_server_flags (a SyncRunner flag sink).
ReadResultdataclassWhat changed, what was unchanged, missing or local-only, and the audit ids.
External none
Notes
  • The write job is readstate.write, one per message. A system actor is refused outright; reasons that are not the user's need confirm=True, which the operation registry passes after its gate.
  • Providers are keyed by account id, so write-back reaches the right mailbox of several.

Per-account and per-folder sync schedules in the database: interval or cron with a timezone, quiet hours, a floor, a concurrency cap, catch-up, backoff, an authentication pause and an audit trail for every edit.

Notifies (event or callback, upward)
none
Notified by
none
Functions
SignatureWhat it does
SyncService.start()Starts the account providers, resumes pending write-backs, ensures schedules and starts the loop. Returns the accounts with no provider and why.
SyncService.set(account, spec, folder=, timezone=, quiet=)Create or change a schedule; refused below the floor. Audited.
SyncService.request(account, folder=)Sync now; joins a running sync. The hook for IDLE or push.
SyncService.crontab()A read-only crontab-style export.
External none
Notes
  • Quiet hours mute alerts only; they never stop a sync.
  • The daemon entry point and oaken auth come with prompts 16 and 17, so no real mailbox is read until then.

Ingest

Ingest pipelineBuilt prompt 09 untrusted inputsrc/oaken/ingest · map

Staged, idempotent, resumable: store, then Sieve filter and index (keyword-searchable from here), then embed and enrich asynchronously. Every stage skips work whose content hash it has already done.

Called by
none
Notifies (event or callback, upward)
none
Types
NameKindMeaning
PipelineclassRegisters the ingest.parse, .embed, .enrich and .reprocess jobs; embed_message, enrich_message, reprocess, backfill_paused and pipeline_status.
SyncRunnerclassPulls from a provider, stores each message, queues parse, saves the cursor only after the batch. A backfill pauses at the queue cap; new mail never does.
BackendsdataclassCallables for the embedder, LLM, enrichers and vector store, so plugins can come and go while the pipeline runs.
VectorsclassThe one seam between core and a vector store. Optional store capabilities (stored_hashes, begin_model, activate_model, drop_model, prune_orphans) are detected, not required.
UsageStoreclassai_usage in raw.db: calls, cached calls, tokens in and out, and tokens saved by caching.
QuarantineclassWrites flagged attachments to an owner-only folder. Never reads, parses or runs them.
PipelineStatusfrozen dataclassPer-stage counts, embedding speed, backfill state and the parked (failed) jobs with reasons.
Functions
SignatureWhat it does
Pipeline.reprocess(stage) -> job idOne low-priority job per stage and account; parse queues index.rebuild, embed swaps models without removing the old vectors first.
SyncRunner.sync(account, provider) -> SyncResultStored, unchanged, deleted, flag changes, paused and errors. Provider errors are reported, not raised.
UsageStore.usage_summary(since) -> list[UsageRow]Per stage and model; enrichment tokens are estimates.
PluginContext.subscribe(event_type, handler)Plugins listen to core and plugin events, read-only; the host ends them when the plugin stops.
External none
Notes
  • Order is filter first, so discarded or skip_enrich mail never reaches the model stages. Embed runs at priority-1 and enrich at priority-2 of the parse job.
  • Failures retry with backoff up to ingest.stage_max_attempts, then are parked and shown in pipeline_status. Keyword search works throughout an outage.
  • A changed embedder model leaves embed jobs 'stale' until reprocess('embed'); new mail during a swap is embedded to the building model only.
  • Enrichments live in raw.db (they cost model time); index.db stays disposable.
  • Several vector stores: only the Vectors seam exists; split and caps are planned (see 08 notes).
  • docs.cleaned_text is duplicated with chunks.text for now; pointers are a later prompt.
Attachment quarantineBuilt prompt 09 untrusted inputsrc/oaken/ingest/quarantine.py · map

Flagged attachments are written, never read back, to a 0700 folder as 0600 .quarantined files named so they cannot escape or execute. Over-size attachments stay in the message.

Calls
Parsing
Called by
none
Notifies (event or callback, upward)
none
Notified by
none
External none
Notes
  • The one place attachment bytes leave raw.db without being asked for; the user chose this in 09. Switch off with mail.attachments.quarantine = false.
  • problems() checks modes only.
ParsingBuilt prompt 06 untrusted inputsrc/oaken/mail · map

RFC 822 to structured fields and cleaned text (quotes, signatures and boilerplate removed); thread keys; token counts. Pure and deterministic: no database, no network, no model.

Calls
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
ParsedMessagefrozen dataclassHeaders, addresses, ids, UTC date, text and HTML bodies, attachment metadata, forwarded messages and warnings. parse() never raises.
CleanedTextfrozen dataclassTagged segments, the cleaned text (new content only), chars and tokens in and out, and untrusted() for the model boundary.
ThreadInfofrozen dataclassThe thread key, a subject key for the index to join reference-less replies, and how the key was derived.
AttachmentInfofrozen dataclassFilename, type, size, content-id, inline, quarantine flag and part path. Never the bytes.
TokenizerProtocolcount(text) -> int. An estimate by default; exact from a local tokenizer.json.
Functions
SignatureWhat it does
parse(raw, settings) -> ParsedMessageStandard-library parser with policy.default; malformed mail becomes warnings.
clean(parsed, settings, tokenizer) -> CleanedTextPick plain or HTML, then tag quotes, signatures, footers, disclaimers and boilerplate by named rules.
thread_key(parsed, settings) -> ThreadInfoStateless key from References, In-Reply-To or Message-ID, with a configurable subject fallback.
html_to_text(html) -> strselectolax: drops scripts, hidden preheaders and tracking pixels; keeps lists, tables, quotes and links.
read_attachment(raw, part_path) -> bytesThe explicit, on-demand door to an attachment's bytes; nothing in ingest calls it.
External none
Notes
  • Every cleaning rule has a name and can be switched off or extended in [mail.cleaning].
  • Attachments are flagged for quarantine by name and type and are never analysed unless asked.
Sieve filteringBuilt prompt 06c untrusted inputsrc/oaken/mail/sieve · map

The user's Sieve (RFC 5228) scripts organize mail as it lands: a disposition, labels, flags and an analysis marker, stored as attributes in raw.db. A small engine of our own; sending actions are recorded and never executed.

Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
Scriptfrozen dataclassA compiled, validated script with its required extensions and a content hash.
SieveResultfrozen dataclassThe disposition (inbox, filed, discarded), the rule that decided it and the effects, each with script:line.
MessageViewdataclassWhat a script may look at: headers, envelope, size and body text. Never attachments.
FilterEngineclassLoads the scripts, filters a message and stores the attributes, or refilters stale messages.
AttrStoreclassmessage_attrs in raw.db: replace per source, list by label or disposition, counts, stale detection.
Functions
SignatureWhat it does
compile_script(name, text) -> ScriptParse and validate; raises SieveError with a line number.
run(scripts, view, settings) -> SieveResultRun scripts in order; a failing script loses only its own actions.
FilterEngine.filter_message(message_id) -> SieveResultRead, parse, run and store in one transaction; publishes MessageFiltered.
FilterEngine.refilter(account_id, limit) -> RefilterResultFilter every message whose last run is missing or made by other scripts. Resumable.
External none
Notes
  • redirect, reject, ereject and vacation are recorded as suppressed intents and never executed.
  • editheader, include, notify and anything that opens a MIME part are refused.
  • Scripts are *.sieve files in the sieve folder next to config.toml, run in name order.

Runtime

In-memory, typed events. Handlers are never dropped; client streams are bounded and drop the oldest.

Calls
none
Notifies (event or callback, upward)
API daemon Ingest pipeline
Notified by
none
Types
NameKindMeaning
Eventfrozen dataclassBase of every event: ts and a UUIDv7 event_id; subclasses add account_id and fields.
EventStreamasync iteratorBounded drop-oldest queue for one client; counts what it dropped.
PluginEventfrozen dataclassBase for events a plugin defines itself; the host stamps the sender's name.
MessageStored, LargeMessageStoredfrozen dataclassesRaised by the raw store after commit: is_new/changed, and a large message kept in LFS.
MessageParsed, MessageIndexedfrozen dataclassesRaised by the Indexer when a message is parsed and when its index entry changed.
IndexRebuildProgressfrozen dataclassDone, total, indexed and failed counts during an index rebuild; finished=True at the end.
Functions
SignatureWhat it does
EventBus.subscribe(type, handler)Internal handler for an event type; failures are logged, never propagated.
EventBus.publish(event)Deliver to every handler and stream; never blocked by a slow stream.
EventBus.stream(filter)An async iterator of events, for SSE later.
External none

Durable queue in raw.db that survives crashes and restarts: leases, retries with backoff, dedupe, priority and per-kind concurrency.

Types
NameKindMeaning
Jobfrozen dataclassOne row of the jobs table.
JobQueueclassEnqueue, claim with a lease, heartbeat, complete, fail, cancel.
JobRunnerclassN asyncio workers; handlers register by kind with a pydantic payload model.
PermanentJobErrorexceptionRaised by a handler to fail without retrying.
Functions
SignatureWhat it does
JobQueue.enqueue(kind, payload, dedupe_key=...)Idempotent while an equal key is queued or running.
JobRunner.register(kind, model, handler, concurrency=...)Run handler(payload, job) for jobs of this kind.
JobRunner.drain()Run everything runnable now; used by tests with a fake clock.
External none
Notes
  • Delivery is at least once, so handlers must be idempotent.
  • See architecture/jobs for the lifecycle.
Agents and the swarmBuilt prompt 03bsrc/oaken/core/agents.py · map

Optional external workers with their own scoped tokens and quotas. Opt-in per job kind, results validated before they are stored.

Called by
API daemon
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
Agentfrozen dataclassAn external worker: label, kinds, accounts, quotas, revocation.
AgentRegistryclassCreate (token shown once, only its hash stored), authenticate, list, revoke.
Actorfrozen dataclassWho did something: user, agent or system.
Functions
SignatureWhat it does
JobQueue.claim(..., agent=agent)Scope and quota checks inside the claim transaction.
JobQueue.complete(job, worker, result, produced_by=)Validates the result against the kind's schema before storing it.
External none
Notes
  • Off by default; a kind is offered to agents only if listed in [swarm] allowed_kinds.
  • Agents claim over HTTP, the CLI (oaken jobs) and MCP (/mcp/agent); all three call the same queue methods.

Recurring work (interval or cron, UTC) with a persisted next_run, so restarts neither repeat nor skip; replaces APScheduler.

Called by
Sync schedule
Notifies (event or callback, upward)
none
Notified by
none
Functions
SignatureWhat it does
Scheduler.add(name, kind, payload, every= | cron=)Create or update a schedule; idempotent at start-up.
Scheduler.tick()Enqueue every due schedule and advance next_run in one transaction.
External none

Entry-point discovery, validation, lifecycle, capabilities and health for the five plugin contracts. Fakes and reusable contract suites live in oaken.testing.

Types
NameKindMeaning
PluginHostclassLoads, starts, health-checks and stops plugins; looks them up by kind and name.
PluginInfofrozen dataclassName, version, kind, capabilities and api_version of a loaded plugin.
BaseProvider, BaseEmbedder, ...classesOptional base classes with defaults; capabilities are inferred from the methods overridden.
Functions
SignatureWhat it does
PluginHost.providers_with(capability)Healthy providers that implement a capability. Sending is never routed.
adapt.to_batch(obj, who)Accept a dict, list or dataclass from a plugin and return the strict type, or a readable PluginError.
PluginContext.publish(event)Announce a plugin-defined PluginEvent on the bus; core events cannot be forged this way.
verify_provider(make, ids)Run the whole provider contract suite in one call (one verify_* per kind).
External none
Notes
  • Core never imports provider code; a plugin never touches the databases.
  • Plugins run in-process and are not sandboxed in v1.

Plugins

ProviderBuilt prompt 11 untrusted inputsrc/oaken/plugins · map

The provider contract: pull, flag write-back, auth and send, each declared by overriding its method. First-party providers are Gmail, SMTP (send only) and IMAP. pull takes an optional folder; one instance runs per account. Nothing routes a send yet.

Calls
none
Notifies (event or callback, upward)
Plugin host
Notified by
none
External Gmail, SMTP server, IMAP server, OS keyring
EnrichersBuilt prompt 14 untrusted inputsrc/oaken_enrichers · map

Summary, triage, actions and the header/MIME inspector: first-party enrichers whose results are stored once at ingest. The three that use a model reach it only through ctx.llm and generate_cached.

Called by
Plugin host
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
SummaryEnricherclassSentences and key points; a reply is summarized from its new text plus the parent's stored summary.
TriageEnricherclassCategory, priority and reply flags; header rules decide bulk, automated and no-reply mail with no model call.
ActionsEnricherclassAction items and deadlines; an item whose quote is not in the message is dropped.
InspectorEnricherclassSPF, DKIM, DMARC, ARC, hops, date, Reply-To, List-* and risky attachments. No model.
Functions
SignatureWhat it does
PluginContext.llm.generate_cached(builder, *parts, content_hash, account_id)The one way a plugin reaches a model; core picks the account's workspace.
python -m oaken.evals enrichScores the eval fixtures against the configured model; manual, never CI.
External none
Notes
  • Built means: the four enrichers run in the real pipeline against FakeLLM, with tests for every rule, the injection fixtures and the cache. Not yet run against a live model: do that with oaken.evals.
  • summary and actions skip bulk mail by default (skip_bulk).
  • Core does not yet attach an LLM service to the host (no daemon until 16): host.llm_access = WorkspaceLLM(service, raw).
Vector storeBuilt prompt 08src/oaken_vec_sqlite · map

Stores and searches vectors in index.db with sqlite-vec (optional extra oaken[vec]). Filters run before the search, so they are exact.

Calls
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
SqliteVecStoreclassThe oaken.vector_stores plugin: upsert, query, stats, health, model lifecycle.
UpsertResultfrozen dataclassHow many vectors were written and how many skipped (same content hash).
ReembedPlanfrozen dataclassChunks done and needed for a new model, and an estimated time.
ModelInfofrozen dataclassOne embedding model: dimensions, table, active or building.
Functions
SignatureWhat it does
upsert(items)All-or-nothing batch; last wins; skips an unchanged content hash.
query(vector, k, filters)Cosine nearest chunks; filters by account, model, thread, date, sender; unknown filters refused.
begin_model / activate_model / drop_modelBuild a new model beside the old one, then switch.
prune_orphans()Delete vectors whose chunk is gone (no triggers are used).
reembed_plan(model_id)Progress and time estimate for a model swap.
External sqlite-vec
Notes
  • float32 by default; int8 is the dtype setting, chosen per model.
  • content_hash is required on every VectorItem.
  • Several stores (optional size cap, manual split) are planned: room is left in 09, nothing is built yet.
  • Measured: 300k x 768 searches in about 0.4 s; see the spike.
Ollama pluginBuilt prompt 10src/oaken_ollama · map

The first-party embedder and LLM backend for a local Ollama. Loopback only unless ai.allow_remote is set; never pulls a model; structured output through Ollama's format schema.

Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
OllamaEmbedderclassThe oaken.embedders plugin: batches, discovers dimensions with a probe.
OllamaLLMclassThe oaken.llm_backends plugin (ollama and ollama-remote): chat with a JSON schema, token counts, locality.
OllamaConfigpydantic model[plugins.ollama]: base_url, model, keep_alive, timeout, max_retries, backoff, batch_size.
Functions
SignatureWhat it does
start(ctx)Checks the address, that the model is installed (else prints ollama pull NAME), and its context size.
is_loopback(url)True only for localhost, 127.0.0.0/8 and ::1.
External Ollama
Notes
  • Retries only connection errors and 5xx. Error messages never carry request or response bodies.
  • Default models nomic-embed-text and qwen2.5:3b are unverified placeholders; test live later.
  • Fixtures are written from the API docs, not recorded.
Gmail pluginBuilt prompt 11src/oaken_gmail · map

The first-party Gmail provider: full and incremental sync, flags, labels, trash, archive, star and send, over Google's REST API with a bring-your-own OAuth client. The refresh token lives only in the OS keyring.

Calls
none
Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
GmailProviderclassThe oaken.providers plugin: authenticate, pull, set_flags, send, plus labels, trash, archive and star.
TokenManagerclassKeeps an access token valid: proactive renewal, one shared refresh, 401 retry, rotation, invalid_grant becomes a health state.
KeyringStoreclassThe refresh token in the OS keyring (service oaken, user gmail:ACCOUNT); no keyring means a clear error, never a file.
GmailApiclassREST client: timeouts, a concurrency cap, backoff on 429, rate-limit 403 and 5xx, Retry-After, one 401 retry.
CursordataclassSync position: a full sync's page (with the history id read before listing) or an incremental history id.
GmailConfigpydantic model[plugins.gmail]: client_secret_file, account, include and exclude labels, page_size, concurrency, timeout, retries.
Functions
SignatureWhat it does
GmailProvider.authorize(show_url)PKCE loopback OAuth in the browser; stores the refresh token in the keyring.
GmailProvider.set_flags(ids, add, remove)Seen is the UNREAD label's absence, Flagged is STARRED; one batchModify, per-id fallback with PartialFailure.
GmailProvider.send(raw)messages.send; never retried after a timeout or 5xx, since the mail may have gone.
External Gmail API, OS keyring
Notes
  • Scope is gmail.modify only; no permanent delete and no drafts.
  • invalid_grant sets needs re-authorization, publishes an event and stops asking until the token changes. A Testing-mode Google app expires tokens in 7 days.
  • Spam and Trash are never synced; moving a message there reports it deleted.
  • Test live later: the fake server proves the requests, not that Google accepts them.
SMTP send pluginBuilt prompt 11bsrc/oaken_smtp · map

A provider that only sends: it submits a message through your own SMTP server over STARTTLS or implicit TLS, never in the clear. It declares only auth and send. The password lives only in the OS keyring.

Calls
none
Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
SmtpProviderclassThe oaken.providers plugin: authenticate (log in once, send nothing) and send. One connection per send.
SendFailedexceptionWhat a refused or failed send raises: accepted and refused recipients with the server's codes, and transient (4xx, network) versus permanent (5xx, TLS).
AuthRequiredexceptionThe password is missing or was rejected; storing a new one fixes it, retrying does not.
KeyringStoreclassThe password in the OS keyring (service oaken, user smtp:ACCOUNT); no keyring means a clear error, never a file.
SmtpConfigpydantic model[plugins.smtp]: host, port, security (starttls or tls only), username, from_address, account, timeout.
Functions
SignatureWhat it does
prepare(raw, from_address)The envelope and bytes to submit: Bcc goes to the envelope only and is removed from the headers, a Message-ID is added if missing, nothing else changes.
SmtpProvider.send(raw)Returns the Message-ID. A partial refusal raises SendFailed listing who got it, so a retry can skip them.
External SMTP server, OS keyring
Notes
  • There is no plaintext option. A server that does not offer STARTTLS is refused before the password is sent, and certificates are always checked.
  • A wrong password (530, 534, 535, 538) makes the plugin unhealthy and publishes an event; no attempt is made again until a different password is stored.
  • Test live later: the in-process fake server proves the protocol handling, not that your provider accepts it.
IMAP providerBuilt prompt 11csrc/oaken_imap · map

A generic IMAP provider for any server: it mirrors every folder except Trash and Junk, finds new, deleted and changed mail per folder, and writes back flags, Trash, Archive and moves. TLS is always on and the password lives only in the OS keyring. It cannot send.

Calls
none
Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
ImapProviderclassThe oaken.providers plugin: authenticate, pull, set_flags, plus trash, archive and move. One connection per pull or write.
ImapConfigpydantic model[plugins.imap]: host, port, security (tls or starttls only), username, account, folder include and exclude lists, trash and archive folders, page_size, flag_scan_limit, timeout.
CursordataclassPer folder: UIDVALIDITY, the highest mod-sequence read, and the UIDs delivered as ranges.
SessionclassOne TLS-only connection: login, LIST, SELECT, UID SEARCH, FETCH, STORE, COPY, MOVE and EXPUNGE, with replies parsed to plain values.
AuthRequiredexceptionThe password is missing or was rejected; storing a new one fixes it, retrying does not.
PartialFailureexceptionSome messages changed and some did not; failed maps each message id to why.
KeyringStoreclassThe password in the OS keyring (service oaken, user imap:ACCOUNT); no keyring means a clear error, never a file.
Functions
SignatureWhat it does
ImapProvider.pull(cursor)New mail is the server's UIDs minus the delivered ones, deletions the reverse; flag changes come from CONDSTORE or a capped re-read; a changed UIDVALIDITY re-reads the folder.
folders.to_sync(all, config)The folders to mirror: selectable, not Trash or Junk (server attributes first, name guesses only when the server gives none), within the include and exclude lists.
ImapProvider.set_flags(ids, add, remove)UID STORE per folder; never sets \Deleted; an id from a renumbered folder fails instead of acting on the wrong message.
External IMAP server, OS keyring, imapclient (BSD)
Notes
  • No plaintext option. A server that does not offer STARTTLS is refused before the password is sent, and certificates are always checked.
  • Messages are fetched with BODY.PEEK, so reading never marks them read.
  • Without UIDPLUS or MOVE nothing is moved, because a plain EXPUNGE could destroy other mail marked deleted.
  • Special-use folder markings come from the plain LIST reply; otherwise name guesses apply. imapclient is synchronous, so every call runs in a worker thread, one at a time.
  • No IDLE and no QRESYNC yet: the scheduler polls, and deletions are found by comparing UID sets.
  • Test live later: the in-process fake server proves the protocol handling, not that your provider accepts it.

Storage

Source of truth: zstd RFC 822 blobs (content-addressed), accounts, provider ids, sync cursors and read-state intents, with an LFS folder for large messages. The job queue shares the file.

Notifies (event or callback, upward)
API daemon Ingest pipeline
Notified by
none
Types
NameKindMeaning
RawStoreclassIdempotent, transactional put; get_raw with sha256 verification; soft delete; cursors; stats; gc_blobs; account create and config.
MessageMetafrozen dataclassWhat a provider knows besides the bytes: internal date, read, starred, labels.
StoredMessagefrozen dataclassA message row as listed by iter_messages, with its size and whether it lives in LFS.
raw_metatableKey/value facts about this store. Holds store_id, a random id the index records so it can refuse a different raw.db.
Functions
SignatureWhat it does
RawStore.put_message(account_id, provider_id, raw, meta) -> (MessageId, is_new)Store one message. Same provider id and bytes is a no-op; changed bytes move the pointer.
RawStore.get_raw(message_id, verify=False) -> bytesThe exact stored bytes; verify=True re-hashes and raises StorageError on corruption.
RawStore.get_stub(message_id) -> bytesThe headers only, without opening the LFS file of a large message.
RawStore.iter_messages(account_id, since, after_id) -> AsyncIterator[StoredMessage]Oldest first; after_id resumes strictly after a checkpointed message.
RawStore.count_messages(account_id, since) -> intLive messages, for rebuild progress.
RawStore.live_message_ids(ids) -> set[str]Which of these ids still exist and are not deleted; the index uses it to prune.
RawStore.store_id() -> strThe id of this raw.db (migration 0006), recorded by index.db.
RawStore.gc_blobs() -> GcResultRemove unreferenced blobs and their LFS files. A referenced blob is never removed.
External none
Notes
  • Large messages (over storage.inline_max_bytes) live whole in the lfs/ folder; raw.db keeps a header stub and a link.
  • Database files and the LFS folder are owner-only. Encrypting LFS is a possible later option.
index.dbBuilt prompt 07 untrusted inputsrc/oaken/index · map

Everything derived and rebuildable from raw.db: cleaned text split into chunks and an FTS5 keyword index with ranked, highlighted search. Disposable: it is bound to one raw.db and can always be rebuilt.

Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
IndexStoreclassindex.db: documents, chunks, labels and the external-content FTS5 table. Hash-skipped writes, keyword search, check and repair, stats.
IndexerclassReads raw.db and writes index.db: index_one, follow_filtering and a resumable rebuild.
ParsedQueryfrozen dataclassA user query read into a safe FTS5 expression plus from, date, label and disposition filters, with warnings.
Chunkfrozen dataclassOne piece of cleaned text with its ordinal, token count and content hash (oaken.mail.chunk).
SearchHitfrozen dataclassOne message: its best chunk, a highlighted snippet, rank, thread, disposition and labels.
Functions
SignatureWhat it does
parse_query(text, settings) -> ParsedQueryQuote every term so FTS5 syntax is plain text; never raises. Supports phrases, -not, prefix*, from:, before:, after:, label: and in:.
chunk_text(text, target, overlap, tokenizer) -> list[Chunk]Split on paragraphs, then sentences, then words, with whole-unit overlap.
IndexStore.index_message(...) -> IndexResultWrite one message's document and chunks; an unchanged content hash writes nothing, an edit rewrites only the chunks that changed.
IndexStore.keyword_search(query, filters, k) -> SearchResultBM25 with subject and sender weighted up, one hit per message, snippets; discarded mail hidden unless asked for.
Indexer.index_one(message_id) -> IndexResult | NoneParse, clean, chunk and index one message; a deleted message is removed instead.
rebuild_index(raw, index, ...) -> RebuildResultWalk raw.db oldest first and index again. Checkpointed, resumable, prunes deleted messages and publishes IndexRebuildProgress.
register_index_jobs(runner, indexer)Handle the index.rebuild job (one at a time, backfill priority, one per account).
External none
Notes
  • External-content FTS5 (not contentless) so snippet() works; triggers keep it in step with the chunks table.
  • index.db records the raw.db store_id and refuses a different store; a lower pipeline_version marks it stale for rebuild.
  • Message text never reaches a log, and attachment bytes are never indexed.
  • Planned, later prompt (with the UI): chunk text as offsets into docs.cleaned_text instead of a second copy. Deferred: it saves about one copy of the text and costs a join on every FTS rebuild.

Foundation

Core foundationBuilt prompt 02src/oaken/core · map

Settings, platform paths, logging, errors, ids, clock and the database access layer.

Calls
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
Settingspydantic modelDefaults < config.toml < OAKEN_* env; unknown keys are an error.
DatabaseclassWrite broker plus a pool of read-only WAL connections (ADR 0003).
TxclassThe writer, lent out inside a transaction() block.
OakenErrorexceptionBase of the error taxonomy; every subclass has a stable machine code.
MessageId / AccountId / ThreadId / JobIdNewTypeTyped internal ids; message, thread and job ids are UUIDv7.
ClockProtocolSystemClock in production, FakeClock in tests.
Functions
SignatureWhat it does
Settings.load() -> SettingsApply the full precedence; raises ConfigError on a bad file or value.
Database.open(path) -> DatabaseOpen a WAL database with a writer and a read pool.
Database.transaction()Async context manager: BEGIN IMMEDIATE, commit, or roll back on error.
migrate(db, directory) -> list[int]Forward-only migrations with checksums; refuses a newer database.
configure_logging(settings)structlog to stderr; redacts secrets and truncates long fields.
External none
Notes
  • OAKEN_HOME relocates every path under one root; tests rely on it.
  • Database files are created owner-only (0600) before SQLite opens them, so -wal and -shm inherit it.
Prompt safetyBuilt prompt 10src/oaken/ai · map

Mail reaches a model only as UntrustedText in a fenced, per-call-nonce block; instructions are constants. Answers are validated against a pydantic schema, with one repair retry. Results are cached per workspace.

Calls
none
Notifies (event or callback, upward)
none
Notified by
none
Types
NameKindMeaning
UntrustedTextfrozen dataclassMail-derived text with no implicit str; render() fences it with a fresh nonce and neutralizes forged delimiters.
PromptBuilderclassOne LLM task: constant instructions, answer model, version (plus a hash of the text) and token cap.
SafeOutputpydantic modelBase of every answer: carries injection_suspected.
LLMServiceclassgenerate_cached(): the only door enrichers use. Cache, backend call, validation, one repair, usage.
LLMCacheclassllm_cache in raw.db, keyed by workspace, task, content hash, model and prompt version.
WorkspacesclassNamed profiles (secure or insecure) that partition the cache and usage and gate the backend.
Functions
SignatureWhat it does
UntrustedText.render(label)The only way to put mail in a prompt; strips invisible characters and look-alike brackets.
LLMService.generate_cached(builder, *parts, content_hash, workspace)Returns a validated answer, from the cache when it can.
Workspaces.for_account(raw, account_id)The workspace named in the account's config, else default.
External none
Notes
  • A secure workspace refuses any backend that does not declare locality = local.
  • The AST lint test fails any module that builds an LLMRequest from a raw string.
  • Test live later: the unit tests prove what is sent, not that a model obeys it.

Project tooling

Tooling

Repo and CIBuilt prompt 01pyproject.toml · map

uv project, ruff, mypy --strict, pytest, secret scanning and CI on three platforms.

Calls
none
Called by
Docs site
Notifies (event or callback, upward)
none
Notified by
none
External none
Docs siteBuilt prompt 01adocs/tools · map

This site: generated from docs/, checked for links and determinism, deployed to GitHub Pages.

Called by
none
Notifies (event or callback, upward)
none
Notified by
none
Functions
SignatureWhat it does
build_site.build(cfg, out, strict) -> list[str]Write the whole site to a directory, deterministically.
check_docs.check(cfg) -> list[str]Build twice and return every problem found.
generated.register(page_id, builder)Register a generated page (ADR index, this map, later references).
External Bootstrap 5.3.3 and mermaid 11.4.1 from jsDelivr (pinned, SRI)
5

Call flows

Each step is one call from one component into another, checked against the declared relationships at build time.

A message arrives (sync and ingest)

#FromToWhat happensNotes
1Sync schedule→Job queueenqueue a sync jobDurable, so a crash loses nothing.
2Job queue→Ingest pipelinerun the job handler callbackA registered callback.
3Ingest pipeline→Plugin hostprovider.pullCore never calls the provider directly.
4Plugin host→Providerfetch new messagesReturns raw bytes and provider ids.
5Ingest pipeline→raw.dbstore the raw messageContent-addressed, so a retry is idempotent.
6Ingest pipeline→Parsingparse and cleanTreated as untrusted input.
7Ingest pipeline→Sieve filteringrun the user's Sieve scriptsLabels, flags and a disposition, saved in raw.db. Nothing is sent or deleted.
8Ingest pipeline→index.dbwrite fields and FTS5Searchable by keyword from here.
9Ingest pipeline→Job queueenqueue embed and enrichAsynchronous, resumable, cached.
10Ingest pipeline→Event busemit message.ingestedClients watching the SSE stream see it.

Ask your mailbox a question

#FromToWhat happensNotes
1CLI→API daemonPOST the questionToken-authenticated, loopback only.
2API daemon→Operation registrydispatch the ask operationPermission class: read-only.
3Operation registry→Retrieval and askask
4Retrieval and ask→index.dbkeyword searchFTS5 candidates.
5Retrieval and ask→Plugin hostembed the query and search vectorsOnly the top chunks go to the model.
6Plugin host→Embedderembed
7Plugin host→Vector storenearest chunks
8Plugin host→LLM backendanswer from the chunksMail is wrapped as UntrustedText.
9LLM backend→Prompt safetywrap and cacheCached by (content hash, model, prompt version).

Open a message (mark as read)

#FromToWhat happensNotes
1CLI→API daemonPOST openA user-initiated action.
2API daemon→Operation registrydispatch the open operationConfirm gate applies to anything that mutates.
3Operation registry→Read statemark readNever done by background work.
4Read state→raw.dbrecord the read-state intentSurvives a failed write-back.
5Read state→Audit loglog the changeSo it can be undone.
6Read state→Plugin hostflags.write
7Plugin host→Providerwrite the flag to the provider
6

Dependency matrix

Row is the caller, column is the target. C is a direct call, B is an event or registered callback.

7

Checks

Run every time the docs are built; the build fails if any of these stops being true.

Result Check Detail
pass Components 35 components, 82 documented functions
pass Relationships 64 calls and 6 events or callbacks
pass Layering Every call goes to the same or a lower layer; every callback goes strictly upward
pass Call graph Acyclic
pass Call flows 3 flows, 26 steps, each following a declared relationship
pass Repository Built and partial components point at paths that exist; planned ones have none yet

Entry points, which nothing else calls because the user or an event drives them: CLI, MCP stdio bridge, Web UI, Core daemon, Attachment quarantine, Ollama plugin, Gmail plugin, SMTP send plugin, IMAP provider, Docs site.

8

Open points

Topic Note
OAuth refresh tokens expiring Having to re-authenticate every day or so is untenable. Prompt 11 must keep the refresh token alive: publish the OAuth app out of Testing mode (Testing-mode refresh tokens expire after 7 days), refresh access tokens silently, and fail clearly on invalid_grant.
SQLite driver Standard sqlite3 in a dedicated executor, or aiosqlite. Prompt 02 decides and records an ADR.
sqlite-vec Prompt 08 starts with a spike to confirm it loads on Linux, macOS and Windows before the VectorStore plugin is built on it.
Embedding throughput on CPU Not yet measured. Tuning is deliberately out of scope until there is real mail to ingest.
Web UI framework The web UI is its own app (oaken-webui) that serves the UI and proxies to core; core stays headless (ADR 0007). The client-side framework for the pages themselves is still deferred — they are plain HTML/CSS/JS for now.
Plugin sandboxing Plugins are in-process and first-party only in v1. Isolation is a later decision.