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 --> MCPHubComponent Details
Mead.Supervisor: The application root supervisor. Enforces arest_for_onestrategy for foundational infrastructure.Mead.Session.Pool(Sessions): ADynamicSupervisorthat starts oneMead.Session.Supervisorper active conversation. Each of those supervises that conversation'sMead.Session.HistoryandMead.Session.Server.Registry: Fast in-memory process registry mapping conversation UUIDs to runtime PIDs for microsecond WebSocket routing.Mead.Router: A statelessMead.Session.TurnHandlerwith no process of its own. It classifies each turn as local or cloud (or obeys aroute: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.Mead.MCP.Hub: The client side of MCP. A supervisor that keeps oneMead.MCP.Connectionper configured server and exposestools_for/2andcall_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.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.Mead.MCP.Server: The server side of MCP. A supervisor, the last child of the root, that starts aBanditlistener runningMead.MCP.Server.Plugonly whenconfig :mead, :mcp_serverhas 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.Mead.Deck.ChannelSupervisor,Mead.Deck.Tickets: The deck transport (section 9): aDynamicSupervisorwith oneMead.Deck.Channelper session that has a deck attached, and the table of single-use WebSocket tickets. The shared listener started byMead.MCP.Serverserves 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
:modeloption, 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_timeouttogether 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.Channelturns each finished turn into typed events (text.deltaas the answer streams,metric.updatedwhen 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:
Mead.Session.History: an agent that holds the turns.Mead.Session.Server: orchestrates the conversation.
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
failedortimeout. - 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_unavailableor: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:telemetrydetaches 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
timeoutorfailedstatus is only in the session history. Mead.stop_session/1keeps the session row, so a resumed conversation returns with what it already spent.Mead.delete_session/1forgets 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:
:reador: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:
Mead.MCP.Transport.Stdioruns a child process over aPort.Mead.MCP.Transport.Httpposts each message withReqand reads a JSON or event-stream answer.
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/cancelledwith 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__toolname the model could never be shown.
Policy
Mead.MCP.Policy decides what the model sees:
:readtools are always shown.:effecttools are shown only when the turn passestools: :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_callscount. - The whole loop runs inside the turn timeout, so a conversation that uses tools may need a
:turn_timeoutabove 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.Configurationvalidates the settings and keeps only a SHA-256 digest of the token, which itsInspecthides.
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:
| Order | Check | Failure |
|---|---|---|
| 1 | Route | 404, 405 |
| 2 | Bearer token (constant time, before the body is read) | 401 |
| 3 | Origin, when present: loopback or listed in allowed_origins (DNS rebinding protection) | 403 |
| 4 | Content type | 415 |
| 5 | Body of at most 1 MiB | 413 |
| 6 | Decoding (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,historystop_session,resume_session,delete_sessionlist_sessions,compact_session,usage
Details
usagereads the ledger throughMead.usage/1andMead.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: trueand a fixed message, never a stack trace or provider text. submit_turnholds the request until the turn ends, bounded by the session'sturn_timeout, which a client may set to at most one hour instart_session. A client that disconnects does not abort the turn.tools: "effect"is honoured only when the operator setallow_effect: true. Otherwise nothing runs.
Limits
- The listener serves at most
maximum_connectionsconnections 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}, whichstart_sessionreports 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,
resourcesandprompts, 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;:interruptwhen 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/2is 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/2andmark_interrupted/1always 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/2cuts 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/0produces.
Resume, stop and delete
Mead.resume_session/2takes the options ofstart_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/1keeps the file.Mead.delete_session/1removes the file and the ledger aggregate.Mead.Session.Pool.stop_session/1returns 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:
- It writes
<id>.log.tmp(mode0600). - It syncs the file.
- 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 reachescompact_after_bytes(default 1 MiB, underconfig :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/1or thecompact_sessionMCP 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
Banditlistener ofMead.MCP.Server. Port, address,allowed_originsand the connection limit come from:mcp_server. The listener starts when either token is set. Mead.Gatewaysits in front: the first path segmentdeckgoes toMead.Deck.Plug, and everything else goes to the MCP plug.
Connecting
- Ticket.
POST /deck/tickettakes 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.Routeras 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"}.
- No id starts a session with
- Socket.
GET /deck/socket?ticket=...redeems the ticket and upgrades to a WebSocket served byMead.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
Originrule is the same as MCP's (Mead.Origin).
- Resume. The socket's first message must be
session.resume {last_sequence}withinresume_deadline_ms. The server answers with an unsequencedsession.snapshotand replays every sequenced event afterlast_sequencefrom 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>.eventsbefore anyone is sent it. The log isMead.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.sendis idempotent onclient_message_id.- Prompts wait behind at most
queue_limitothers. - Each turn runs through
Mead.submit_turn/3withroute: :auto, tools: :effect. - When a turn ends and nothing is queued, a log longer than
event_log_limitis compacted.EventLog.compact/2keeps the newest events plus the latestpolicy.updated, rewritten through a temporary file and a rename. session.snapshotcarriesfirst_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_idis forgotten, so a resend of that old message would be accepted again. stop_session/1keeps the event log, anddelete_session/1removes 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:
:readis low and:effectis high. - The policy level decides what happens. It is the last
policy.updatedin the log, elsedefault_policy_level.
| Policy level | What happens |
|---|---|
allow_all | Emits permission.requested and at once permission.resolved with decided_by_policy. |
ask_for_risky | Runs a :read call silently. Anything else emits permission.requested and waits. |
ask_always | Emits permission.requested and waits. |
While a request waits
- The turn clock is paused (
Mead.Session.Server.pause_clock/1). - The first valid
permission.respondwins. Its answer isallow_once,allow_alwaysorreject_once. decided_by_deviceis the optionaldevice_idthe client sent insession.resume, elsedevice-<n>, numbered per channel as sockets resume.- A later answer gets the existing
permission.resolvedagain, on that socket alone. permission_timeout_msresolves the request asexpired.- 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_alwaysallows the call and remembers the tool as<server>__<tool>. Itspermission.resolvedcarriesremember.- The next request for that tool is allowed at once with
decided_by_rule. Underallow_allit staysdecided_by_policy. - The rules are rebuilt from the log and kept through compaction.
permission.forget {tool}takes one back with apermission.forgottenevent.
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_mswhatever 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 exceedheartbeat_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 validchoice.respondwins. permission_timeout_msresolves 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 onetext.delta. - A running tool call is registered with the channel (
register_call/3), sotool_call.cancelcan stop it. The model sees the call as failed. - Each finished turn emits two
metric.updatedevents,tokens_inandtokens_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 asfailed. - 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.failedwith reasoninterrupted, and its open tool cards are closed asfailed. - 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.