mead Architecture Specification

Copy Markdown View Source

Component: Core BEAM/OTP Runtime & Smart Router
Ecosystem Role: High-concurrency engine powering AI assistant sessions


1. High-Level Supervision Tree

graph TD
    subgraph core_otp ["Core BEAM / OTP - Supervision Tree"]
        RootSup["Mead.Supervisor"]
        SessionSup["DynamicSupervisor - Sessions"]
        Registry["Registry - Session Registry"]
        RouterEngine["Mead.Router - Stateless Model Dispatcher"]
        MCPHub["Mead.MCP.Hub - Tool Registry & Protocol"]
        MCPServer["Mead.MCP.Server - HTTP Listener for Clients"]
        Metrics["Mead.Ledger - Token Usage Aggregates"]
    end

    RootSup --> SessionSup
    RootSup --> Registry
    RootSup --> MCPHub
    RootSup --> MCPServer
    RootSup --> Metrics

    subgraph active_session ["Active Session - Actor Per Conversation"]
        SessionProc["Session GenServer - Conversation Process"]
        ContextBuffer["Context & Turn History - Memory & Store"]
    end

    SessionSup --> SessionProc
    SessionProc --> ContextBuffer
    SessionProc --> RouterEngine
    SessionProc --> MCPHub

Component Details

  1. Mead.Supervisor: The application root supervisor. Enforces a rest_for_one strategy for foundational infrastructure.

  2. Mead.Session.Pool (Sessions): A DynamicSupervisor that starts one Mead.Session.Supervisor per active conversation. Each of those supervises that conversation's Mead.Session.History and Mead.Session.Server.

  3. Registry: Fast in-memory process registry mapping conversation UUIDs to runtime PIDs for microsecond WebSocket routing.

  4. Mead.Router: A stateless Mead.Session.TurnHandler with no process of its own. It classifies each turn as local or cloud (or obeys a route: override), calls the provider in that slot, and emits [:mead, :router, :route] events. It is not a child of the root supervisor, so no conversation shares a router process with another.

  5. Mead.MCP.Hub: The client side of MCP. A supervisor that keeps one Mead.MCP.Connection per configured server and exposes tools_for/2 and call_tool/4; it comes after the session pool, so a crash of the hub restarts the hub and the MCP server after it, and nothing before it.

  6. Mead.Ledger: A ledger of what the router sends to providers, the first child of the root supervisor. It keeps usage aggregates per session and per route and a ring of the last 100 turns, written by a telemetry handler in the process that emitted the event, and journals them to a separate file in the storage directory so they survive a restart. Budgets and prices are not part of it.

  7. Mead.MCP.Server: The server side of MCP. A supervisor, the last child of the root, that starts a Bandit listener running Mead.MCP.Server.Plug only when config :mead, :mcp_server has a token. Clients such as the deck use it to start sessions, submit turns, read the history, stop, resume, delete, list, and compact sessions as nine MCP tools.

  8. Mead.Deck.ChannelSupervisor, Mead.Deck.Tickets: The deck transport (section 9): a DynamicSupervisor with one Mead.Deck.Channel per session that has a deck attached, and the table of single-use WebSocket tickets. The shared listener started by Mead.MCP.Server serves the deck too.

Implemented so far: the registry, the turn supervisor, the session pool, the router core with the Anthropic provider, the ledger, and both sides of MCP: the hub (client) and the HTTP server. The Ollama provider fills the local slot with text and tools, and session persistence writes each conversation to disk so it can be resumed.


2. Hybrid Model Scheduler Flow

flowchart LR
    Msg["User Input"] --> Classifier{"Fast Classifier - Heuristic / Small Model"}
    Classifier -->|"Routine, Triage, Formatting"| LocalExec["Local Executor - Ollama"]
    Classifier -->|"Complex Reasoning, Synthesis"| CloudExec["Cloud Executor - Anthropic"]
    LocalExec --> TurnHandler["Response Formatter & Action Cards"]
    CloudExec --> TurnHandler
    TurnHandler --> Ledger["Mead.Ledger - Token Usage"]
  • Local Executor: Handles zero-cost tasks with full privacy (command parsing, email categorization, status checks). It is an Ollama model, chosen by the operator with the required :model option, and it may call MCP tools under the same policy as the cloud executor; a model without tool support fails the turn with :tools_unsupported. The first request after Ollama unloaded the model pays the load (28 s for a 2 GB model in a real run), longer than the default 25 s :receive_timeout, so a deployment sets the optional :keep_alive (sent to Ollama as is) and raises :receive_timeout together with the turn timeout.
  • Cloud Executor: Handles complex multi-step reasoning, ambiguous disambiguation, and creative synthesis.
  • Turn Handler: Returns the model's text and usage to the session. The deck does not receive structured cards: Mead.Deck.Channel turns each finished turn into typed events (text.delta as the answer streams, metric.updated when it ends), see section 9.

3. Session Internals

Structure

Each conversation is a Mead.Session.Supervisor started by Mead.Session.Pool. It supervises two processes with a rest_for_one strategy:

Failure handling

  • The history starts first, so a crashed server restarts with the same session id and the same history. A turn that was running is marked interrupted.
  • A server that crashes more than three times in five seconds takes its whole pair down, and that conversation is gone. Neighbouring conversations are never affected.
  • A turn runs in a task under Mead.TurnSupervisor, linked to the server, which traps exits.
  • A handler that raises, exits, returns garbage, or exceeds the turn timeout fails only that turn, with the status failed or timeout.
  • A server death takes its running task with it.

4. Router Internals

Choosing the slot

Mead.Router.route/4 takes the forced route, or asks Mead.Router.Classifier. The classifier is a pure function. It sends a turn to the local model only when all three hold:

  • the input is short;
  • the history is light;
  • the turn asks for triage or formatting without asking for reasoning.

Any doubt goes to the cloud.

Slots and providers

The route names a slot, and a slot holds a {provider, options} pair that implements Mead.Provider.

  • A turn routed to an empty slot fails with :local_unavailable or :cloud_unavailable. It is never sent to the other slot.
  • Only completed text turns are replayed to the provider as history.
  • A failed turn replies {:error, {:failed, reason}}.

Streaming

A provider may also export converse_stream/4.

  • Anthropic reads server-sent events through Mead.Provider.ServerSentEvents. Ollama reads one JSON object per line.
  • Both fold the stream into the body a plain call returns and parse it the same way, so a streamed turn and a plain turn give the same result.
  • When the turn has an interaction and the provider can stream, the router passes it a callback that reaches text_delta/2. The text of every model call in a tool loop is then streamed.
  • The turn's output is the text of every call joined with a blank line. That is also the separator the stream carries, so a replay shows what the live view showed.

5. Ledger Internals

What it records

Mead.Session.Server adds session_id to the options it passes to the handler, and Mead.Router puts it in the metadata of [:mead, :router, :route].

Mead.Ledger owns two public ETS tables and attaches a :telemetry handler. The handler validates each event and updates counter rows atomically with :ets.update_counter, in the process that emitted the event.

  • An event it cannot read is counted as dropped. The handler never raises, because :telemetry detaches a handler that does.
  • Totals are kept per session and per {route, provider}. The last 100 turns sit in a ring.
  • A turn killed by the turn timeout, or whose provider raises, emits no event and so is not counted. Its timeout or failed status is only in the session history.
  • Mead.stop_session/1 keeps the session row, so a resumed conversation returns with what it already spent. Mead.delete_session/1 forgets the row, while the route totals keep what it added.
  • The accessors read the tables from :persistent_term, so a ledger that is stuck on the disk does not block them.

The journal

With a storage directory, the ledger also appends every counted turn, every forget and every dropped event to ledger.journal. The file uses the record framing of the session files (Mead.Session.Record).

  • Writes go in batches of 100 events or 50 ms, made by the ledger process, so a turn never waits for the disk.
  • At boot the ledger replays the journal before it attaches the handler. A torn tail is cut with a warning, and a damaged file is set aside as ledger.journal.corrupt.
  • When the file passes ledger_journal_max_bytes (default 1 MiB), it is rewritten as one checkpoint built from the journal itself, never from the live tables, so no event is counted twice.
  • The ledger is the first root child, so a crash of the ledger itself restores its numbers from the journal instead of wiping them.

6. MCP Hub Internals

Configuration

config :mead, :mcp, servers: [...] lists the MCP servers. Each entry has:

  • a transport: {:stdio, command:, arguments:} or {:http, url:, headers:};
  • the tools the operator allows, each with a class declared in the configuration: :read or :effect.

Mead.MCP.Configuration validates it when the application starts and refuses a typo instead of silently granting or denying the wrong tool.

The class is never read from the server. A tool the server offers and the configuration does not list does not exist for the model, whatever the server says about itself.

Connections

Mead.MCP.Connection is one process per server. It owns a transport:

The connection performs the handshake with the pinned protocol version, follows the tool list across pages, publishes the tools in Mead.MCP.Catalog, and refreshes them on a tools/list_changed notification.

When something goes wrong

  • A transport that cannot start, closes, or dies does not crash the connection. It removes its tools, fails its pending callers with :connection_lost, and retries after 1, 2, 5, 15, then 30 seconds. A missing command therefore cannot become a restart loop that takes the application down.
  • When a call's caller dies or its timer runs out, the connection sends notifications/cancelled with the request id so the server can stop the work. The server may ignore it, and a late answer is dropped.
  • A refresh the server never answers is abandoned after the handshake timeout, and the connection reconnects.
  • Stopping a stdio transport closes the child's stdin and then signals the child, because closing the port alone does not end it.

Bounds on what a server says

  • Descriptions are cut at 2 000 characters.
  • A schema that is not an object, or is over 20 000 bytes, becomes the permissive default.
  • An HTTP response over 4 MiB fails its request with :response_too_large.
  • The configuration refuses a tool whose server__tool name the model could never be shown.

Policy

Mead.MCP.Policy decides what the model sees:

  • :read tools are always shown.
  • :effect tools are shown only when the turn passes tools: :effect.
  • Anything else is exactly the default.

The model sees a tool as server__tool. call_tool/4 authorizes the name again before any server is contacted, so a tool the model was not shown cannot run.

A result longer than 50 000 characters is truncated with a marker, and non-text content becomes a placeholder.

The tool loop

When the provider of the chosen slot implements converse/3 (on either route) and the policy shows at least one tool, Mead.Router runs a loop of up to eight provider calls through the optional callback Mead.Provider.converse/3.

  • Tool errors, denials, and unknown tools go back to the model as error results. They never fail the turn.
  • The route event carries the sum of the tokens of every call and a tool_calls count.
  • The whole loop runs inside the turn timeout, so a conversation that uses tools may need a :turn_timeout above the default of 30 seconds.

Known limit: the HTTP transport has been exercised only against a test double, not a real remote server.

7. MCP Server Internals

Turning it on

config :mead, :mcp_server,
  token: "...",
  port: 4040,
  address: {127, 0, 0, 1},
  allow_effect: false,
  allowed_origins: [],
  maximum_connections: 250
  • Without a token, nothing listens and one info line says so.
  • An invalid setting stops the application at boot, with a message that never contains the token.
  • Adding certfile: "cert.pem", keyfile: "key.pem" (both or neither) makes the one listener speak HTTPS, and WSS for the deck. No plain-HTTP port is left open.
  • Mead.MCP.Server.Configuration validates the settings and keeps only a SHA-256 digest of the token, which its Inspect hides.

The endpoint

The endpoint is POST /mcp (MCP Streamable HTTP, protocol version 2025-06-18). It is stateless: no mcp-session-id is assigned, GET /mcp is 405, a notification is 202, and every other message gets one JSON object.

Mead.MCP.Server.Plug runs its checks in a fixed order and answers the first failure:

OrderCheckFailure
1Route404, 405
2Bearer token (constant time, before the body is read)401
3Origin, when present: loopback or listed in allowed_origins (DNS rebinding protection)403
4Content type415
5Body of at most 1 MiB413
6Decoding (a batch is refused)400

A bug in the dispatcher becomes a generic 500. Its log line names only the exception module, because a crash report can print the Authorization header.

The dispatcher and the tools

Mead.MCP.Server.Dispatcher is pure. It answers initialize, ping, tools/list, and tools/call, with -32601 for an unknown method and -32602 for params of the wrong type.

Mead.MCP.Server.Tools offers nine tools and calls only the public Mead API:

  • start_session, submit_turn, history
  • stop_session, resume_session, delete_session
  • list_sessions, compact_session, usage

Details

  • usage reads the ledger through Mead.usage/1 and Mead.session_usage/2. It returns the totals over every route with the dropped count, the totals per route and provider, and the latest 0 to 100 turns (20 by default), or the totals of one session. The three parts are separate reads, not one snapshot, so a turn counted meanwhile may show in only some of them. Providers are shown as module names and times as ISO 8601. A ledger that is not running is an error result.
  • A tool that fails while running (an unknown session, a failed turn, a timeout) is a result with isError: true and a fixed message, never a stack trace or provider text.
  • submit_turn holds the request until the turn ends, bounded by the session's turn_timeout, which a client may set to at most one hour in start_session. A client that disconnects does not abort the turn.
  • tools: "effect" is honoured only when the operator set allow_effect: true. Otherwise nothing runs.

Limits

  • The listener serves at most maximum_connections connections at once (rounded up to a multiple of 10 from 10 on). It closes one that finds no room after about a second.
  • The session pool refuses a session beyond config :mead, :maximum_sessions (default 1000) with {:error, :max_children}, which start_session reports as "the session could not be started".
  • The root supervisor tolerates 10 restarts in 5 seconds.

Not in this slice

  • SSE and progress events, several tokens or roles, relaying the hub's own tools, resources and prompts, and IPv6 addresses. (The deck's WebSocket and TLS are described in section 9.)
  • The 100 000 character input limit and the 1 MiB body limit are first values, not measured.

8. Persistence Internals

The files

With config :mead, :storage, directory: "...", every session writes its turns through Mead.Session.Store. It is a plain module and the only code that touches the files.

A session is one append-only file named after its id. Each record has this layout:

[4-byte length][4-byte CRC32 of the length][4-byte CRC32 of the payload][term_to_binary payload]

Each record is the encoding of one event:

  • {:append, turn} when a turn starts;
  • {:replace_last, turn} when the newest turn is closed;
  • :interrupt when turns still running became interrupted.

Replaying the events rebuilds the list the history agent held. Without the setting nothing is written, and Mead.resume_session/2 answers {:error, :not_found}.

Writing

Mead.Session.History keeps the directory in its state.

  • Only append/2 is write-first. A turn that cannot be recorded is refused with {:error, {:persistence, reason}} before the handler starts, and memory is left untouched.
  • update_last/2 and mark_interrupted/1 always change memory and then try to write, because the caller already holds the answer. A failed write there is logged once and the reply is unchanged.
  • A closed turn that never reached the disk comes back after a restart as an interrupted turn.
  • A write that fails is rolled back to the size the file had before it.
  • Resuming and deleting the same id are serialised with a lock.

Reading and damage

  • A record that is cut short, or fails its payload checksum, at the end of the file is what a crash during a write leaves. It is dropped with a warning, and Mead.resume_session/2 cuts it off the file before the session writes again.
  • The length has a checksum of its own, so a damaged header is never mistaken for a cut write.
  • Damage anywhere else is {:error, :corrupt_history}, because a conversation with a hole in the middle is worse than none.
  • Payloads are decoded without :safe, so that a conversation holding a failed turn (an exception or atom from a module a fresh node has not loaded) can be resumed. The files are private (mode 0600) and checksummed, and the directory must be trusted and single-writer.
  • A session id becomes a file name only if it has the shape Mead.UUID.generate/0 produces.

Resume, stop and delete

  • Mead.resume_session/2 takes the options of start_session/1 (the handler and the timeout are not stored), loads the file, and starts the pair with the stored turns. The server then marks the turns that were running as interrupted and records that.
  • Mead.stop_session/1 keeps the file.
  • Mead.delete_session/1 removes the file and the ledger aggregate.
  • Mead.Session.Pool.stop_session/1 returns only once the registry has released the id, so a stop followed at once by a resume or start of the same id cannot find the old entry.

Listing

Mead.list_sessions/0 lists the stored conversations newest first. Each entry has its :session_id, the :updated_at of its file, and whether it is :running.

Mead.Session.Store.list/1 reads only file names and times, never content. Its cost does not grow with the length of the conversations, and a damaged file is listed like any other (it fails at resume).

Compaction

Every turn leaves two records, {:append, turn} when it starts and {:replace_last, turn} when it closes, so a file is about twice the size it needs.

Mead.Session.Store.compact/3 rewrites it as one {:append, turn} per turn:

  1. It writes <id>.log.tmp (mode 0600).
  2. It syncs the file.
  3. It renames it over the log.

Store.load/2 returns the same turns before and after, a crash leaves the old file or the new one, and a file that would not shrink is left alone.

Compaction runs at three points:

  • at resume_session/2;
  • inside History, once a live file reaches compact_after_bytes (default 1 MiB, under config :mead, :storage) and is also at least 1.5 times its size after the last compaction, which keeps the rewrite from happening on every turn;
  • on request, through Mead.compact_session/1 or the compact_session MCP tool.

A failed compaction only logs a warning. It never fails a turn, a resume or a stop.

A resume marks the turns that were running as interrupted before it compacts, so a file that shrinks is already canonical after one resume. One with nothing to remove is left alone, and the history writes the interrupt marker as before.

Trimming old turns

Compaction keeps every turn unless config :mead, :storage, max_turns: N is set. Then the live compaction, the compaction at resume and compact_session/1 keep only the newest N turns, in memory and in the file, and the older ones are gone. It is off by default, because it is the one setting that deletes conversation.

Known limits

  • The file still grows with the number of turns (compaction only halves it).
  • The rollback itself can fail on a dying disk.
  • The ledger is journaled in its own file, not in the session files.

9. Deck Transport Internals

Turning it on

config :mead, :deck,
  token: "...",
  ticket_ttl_ms: 10_000,
  permission_timeout_ms: 120_000,
  heartbeat_ms: 15_000,
  idle_timeout_ms: 90_000,
  resume_deadline_ms: 10_000,
  queue_limit: 10,
  event_log_limit: 10_000,
  default_policy_level: :ask_for_risky
  • The token is a secret separate from the MCP token. Without it, no deck route exists.
  • The deck shares the one Bandit listener of Mead.MCP.Server. Port, address, allowed_origins and the connection limit come from :mcp_server. The listener starts when either token is set.
  • Mead.Gateway sits in front: the first path segment deck goes to Mead.Deck.Plug, and everything else goes to the MCP plug.

Connecting

  1. Ticket. POST /deck/ticket takes the deck bearer token (checked in constant time before the body is read) and an optional {"session_id": "<uuid>"}.
    • No id starts a session with Mead.Router as handler.
    • A running id is reused.
    • A stopped id with a stored history is resumed.
    • Anything else is 404.
    • The answer is {"ticket", "session_id", "expires_in_ms"}.
  2. Socket. GET /deck/socket?ticket=... redeems the ticket and upgrades to a WebSocket served by Mead.Deck.Socket.
    • Redeeming is an atomic ETS take, so two attempts with one ticket upgrade at most one.
    • A request that is not a valid upgrade is refused with 426 before the ticket is touched.
    • The Origin rule is the same as MCP's (Mead.Origin).
  3. Resume. The socket's first message must be session.resume {last_sequence} within resume_deadline_ms. The server answers with an unsequenced session.snapshot and replays every sequenced event after last_sequence from the log.

The channel and the event log

Mead.Deck.Channel is the state: one process per session that owns the event sequence, the prompt queue and the running turn. A reconnect or a second device therefore finds the same state.

  • Every sequenced event is appended to <id>.events before anyone is sent it. The log is Mead.Deck.EventLog: mode 0600, one record per event on the session file framing, a torn tail cut on open. A log that cannot be written stops the channel.
  • prompt.send is idempotent on client_message_id.
  • Prompts wait behind at most queue_limit others.
  • Each turn runs through Mead.submit_turn/3 with route: :auto, tools: :effect.
  • When a turn ends and nothing is queued, a log longer than event_log_limit is compacted. EventLog.compact/2 keeps the newest events plus the latest policy.updated, rewritten through a temporary file and a rename.
  • session.snapshot carries first_sequence, the lowest sequence a replay can start from, so a client resuming from an older one knows the middle is gone.
  • A dropped prompt's client_message_id is forgotten, so a resend of that old message would be accepted again.
  • stop_session/1 keeps the event log, and delete_session/1 removes it.

Permissions

Each deck turn carries Mead.Deck.Interaction, so Mead.Router asks the channel before every tool call (Mead.Router.Interaction). A turn without one keeps the old path. A denied call never reaches Mead.MCP.Hub.

Deciding

  • The tool's class decides the risk: :read is low and :effect is high.
  • The policy level decides what happens. It is the last policy.updated in the log, else default_policy_level.
Policy levelWhat happens
allow_allEmits permission.requested and at once permission.resolved with decided_by_policy.
ask_for_riskyRuns a :read call silently. Anything else emits permission.requested and waits.
ask_alwaysEmits permission.requested and waits.

While a request waits

  • The turn clock is paused (Mead.Session.Server.pause_clock/1).
  • The first valid permission.respond wins. Its answer is allow_once, allow_always or reject_once.
  • decided_by_device is the optional device_id the client sent in session.resume, else device-<n>, numbered per channel as sockets resume.
  • A later answer gets the existing permission.resolved again, on that socket alone.
  • permission_timeout_ms resolves the request as expired.
  • A request whose asker died expires too. A channel that starts with an unresolved request in its log expires it before serving anyone.

Remembering a choice

  • allow_always allows the call and remembers the tool as <server>__<tool>. Its permission.resolved carries remember.
  • The next request for that tool is allowed at once with decided_by_rule. Under allow_all it stays decided_by_policy.
  • The rules are rebuilt from the log and kept through compaction.
  • permission.forget {tool} takes one back with a permission.forgotten event.

Request details are the model's arguments cut at 2 000 characters each, and they are never logged.

Frames, heartbeat and idle timeout

  • A client frame that is binary, over 64 KiB, not JSON, not an object, of an unknown type or with an invalid payload is ignored and logged by reason only. More than 20 in a minute close the socket (1008).
  • The transport itself bounds a frame at 1 MiB and closes the connection (1009) above that.
  • A heartbeat goes out every heartbeat_ms whatever a turn is doing, followed by a WebSocket ping.
  • Any frame from the client (text, ping or pong) counts as activity. A socket with none for idle_timeout_ms (default 90 000, must exceed heartbeat_ms) is closed with 1001 "idle". A client that answers pings by itself therefore never times out.

Questions

When a turn has an interaction and at least one other tool, the router offers the model a built-in ask_user tool (a prompt and 2 to 6 options).

  • The router answers it itself. It is never gated by the policy and never sent to the hub. MCP tool names always contain __, so no server can shadow it.
  • The channel emits choice.requested, pauses the clock like a permission, and the first valid choice.respond wins.
  • permission_timeout_ms resolves it with no option, and the model then reads "The person did not choose in time."
  • A turn whose provider cannot be asked gets "The person could not be asked."

Streaming and cancellation

  • Each fragment a streaming provider produces becomes a sequenced text.delta, and a turn that streamed does not emit its final text again. A provider that cannot stream still emits its whole answer as one text.delta.
  • A running tool call is registered with the channel (register_call/3), so tool_call.cancel can stop it. The model sees the call as failed.
  • Each finished turn emits two metric.updated events, tokens_in and tokens_out.
  • A turn that ends in an error emits turn.failed {turn_id, reason}. A stream cut off midway keeps its fragments and then fails. Tool cards still open at that moment are killed and closed as failed.
  • A tool process also ends when the router that started it dies.

Recovery

Each turn ends with one tokens_out metric that carries its turn_id. A new channel uses it to find the prompts that never finished in the log.

  • The first of them was running when the channel died. It is reported as turn.failed with reason interrupted, and its open tool cards are closed as failed.
  • The ones behind it run again.

Live test

The opt-in test test/mead/deck/live_ollama_test.exs (mix test --include live) drives the whole stack against a local llama3.2:3b.