OAKENMAIL
Pre-alpha

The daemon and the local API

Core runs as one process that owns the databases, the plugins and the job queue, and serves a small HTTP API on 127.0.0.1. The CLI and the web UI are clients of it; the MCP server is on the same port. This page covers how it starts and stops, and how it keeps a web page or another program from using it.

1

One process

oaken.daemon is the composition root. It opens every store and service once and wires them in a fixed order:

  1. settings;
  2. the databases, running migrations;
  3. the plugin host (providers, embedder, model backends, enrichers);
  4. the job runner and the sync schedule;
  5. the API, last, so nothing is asked of a half-started core.

Stopping runs the other way: the API stops accepting and ends its event streams, jobs get [api] shutdown_grace_seconds to finish or hand their leases back, the sync schedule and plugins stop, then the databases close. A process that is killed instead is covered by lease expiry, as for any worker (see jobs).

python -m oaken.daemon      # the foreground process: Ctrl-C or SIGTERM stops it cleanly

The same lifecycle is available to Python as oaken.daemon.serve, detach, status, stop and restart. The CLI's oaken start [--detach], stop, status and restart are thin wrappers over them. status reads only the runtime file, so it works with no token and never starts anything.

--detach starts the same foreground process in a new session on Linux and macOS (closing the terminal does not stop it) and as a detached process in its own group on Windows. Windows has no console for it, and it stops on CTRL_BREAK; stop sends that. Standard input and output go to the null device either way.

2

Finding it: the runtime file

While core runs, runtime.json in the runtime directory holds pid, host, port, version and started_at. It is created owner-only (0600), and it is the single-instance lock: it is created exclusively, with port 0 meaning "starting", before anything is opened. If it already exists and its process is alive, the second start stops with "core is already running". If that process is gone the file is stale and is replaced. A clean shutdown removes it, and only the process that wrote it removes it.

The API port defaults to 0, a free port the kernel chooses, and the file is how clients find it. Set [api] port for a fixed one.

3

The token

On first start core makes a random 256-bit token and stores it in api_token in the config directory, mode 0600. Every request but one needs Authorization: Bearer <token>. The token is compared in constant time. GET /v1/health is open and answers {"status": "ok"}, nothing more.

Agents have tokens of their own (oaken_agent_..., made by create_agent) that open only /v1/agent/. See the swarm.

4

What a request passes through

A browser can send a request to 127.0.0.1:PORT from any page the user has open, so being on loopback is not protection. Each request is checked in this order, by one guard that wraps every route:

Check Refusal Stops
Host is 127.0.0.1:<port> or localhost:<port> 400 DNS rebinding
Preflight answered only for the one configured dev origin no CORS headers a page reading responses
A mutating request with a foreign Origin or Sec-Fetch-Site: cross-site 403 cross-site request forgery, even with a leaked token
A POST has Content-Type: application/json 415 form and text/plain posts a page can send without a preflight
A bearer token for the surface being addressed 401 another process or page without the token
A per-client token bucket on mutating requests 429 a loop, a runaway agent
Body size 413 memory exhaustion
Concurrent requests per surface 429 one door starving another

Responses carry X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store on the API, and a strict Content-Security-Policy on static pages. Requests are never logged, because a URL can hold a search.

5

Who is calling

The registry has a caller class, which decides whether a person's confirm is accepted (see operations). Over HTTP:

The MCP server mounts at /mcp on this listener as its own surface with its own token (mcp_token, mode 0600) and limits, and the mount, not a header, sets its class (ADR 0004). It is off until [api] mcp = true, and then /mcp exists; with it off the path is a 404. With the swarm on as well, a second door at /mcp/agent takes agent tokens and offers only the job tools. A door is exactly its own path. See Connect an AI client.

6

The routes

Operations are not routed by hand. GET /v1/operations lists the registry with JSON Schemas and POST /v1/operations/{name} calls one through the same client the CLI uses in-process. The HTTP API reference is generated from the OpenAPI document, which is served at /v1/openapi.json. Everything else is small:

7

Threat model

Attacker Vector Mitigation Test
A web page the user visits fetch to 127.0.0.1:PORT No CORS headers, so it cannot read a response; JSON-only POST; Origin and Sec-Fetch-Site refused; the token is not in the page's reach test_guard.py
A web page, by DNS rebinding A name that resolves to 127.0.0.1 Host allowlist, checked first test_a_wrong_host_is_refused_before_anything_else
A web page, by a plain form text/plain or form POST 415, and Origin refused test_a_post_must_be_json
Another machine Connecting to the port Binds 127.0.0.1 only; configuration cannot say otherwise; the server refuses another address test_a_non_loopback_bind_cannot_be_configured
Another local user Reading the token or runtime file Both 0600 from creation; the directories are 0700 test_the_token_file_is_private, test_the_file_is_private_from_the_first_byte
Anything on the machine without the token Guessing it 256 random bits; constant-time compare; the rate limit test_the_token_is_compared_in_constant_time
A compromised or confused agent Using its token on the general API Agent tokens open /v1/agent/ only, and the API token opens no agent route test_an_agent_token_is_accepted_by_no_other_route
An MCP client Claiming to be a person X-Oaken-Client: mcp only lowers; a confirmation token is bound to the class that was given it test_the_header_can_only_lower_privilege
An agent Revoked but still holding a lease The lease goes back to the queue; its next call is a 401 test_a_revoked_agent_is_refused_at_once
A caller Huge bodies, floods, many parallel calls Size, rate and concurrency limits per surface test_a_body_over_the_limit_is_refused, test_mutating_requests_are_rate_limited_per_client
A second core Starting on the same data Runtime file as an exclusive lock test_a_second_process_exits_with_a_clear_error
A crash A stale runtime file Replaced when its pid is gone test_a_stale_runtime_file_is_replaced_by_a_start
A log reader Message text in logs or events Events and errors carry ids only; requests are not logged test_event_payloads_carry_ids_and_never_message_text

Not covered: a process running as the same user can read the token file and do what the user can. The audit log records what it did.

8

Not built yet