Architecture

Collectors, the door, the job ledger, admission and retention.

How Elixir MCP is designed, for people who are curious and for anyone who needs to reason about what the service will and won't do. This page is the description of record; there is no fuller internal one.

The shape of the system

Claude, ChatGPT, … Web interface explorer speaks the same tools Discord agents MCP / OAuth email session service token Elixir MCP PostgreSQL S3 archive job ledger, priority-ordered by Elixir MCP (collectors never choose targets) Collector 1 Collector 2 Collector n IP-bound keys · ONE shared rate budget, fleet = resilience Clash Royale API

Everything cloud-side is serverless: RDS Postgres, S3 and eight Lambdas in one CloudFormation stack, inside a VPC with no NAT. The web API serves Home, Ladder, Clan, the JSON API and battle pages; MCP serves the MCP and OAuth doors; collector is the collector door; scheduler plans fetch jobs into the Postgres job ledger on every tick; jobs runs scheduled product work (mail, sweeps, Clan's morning run); timeline sync brings a cross-posted timeline or a clan's Discord activity up to date when something new lands; migrate applies migrations during a deploy and runs operator commands; and the email relay, the one function outside the VPC, carries what must reach the internet (below).

The only machines with Clash Royale API keys are collectors, operator-run workers that lease fetch jobs from the job ledger, fetch with their IP-allowlisted key, and post results back. They never choose their own targets, hold no user data, and earn their operator the Family role while active. The fleet shares one global rate budget by design: more collectors mean resilience, never more API load. The budget is a token bucket the scheduler plans from, and everything that asks the API for a read spends it: each scheduled job, and each live read an agent, a JSON API refresh or a newly seen card asks for. A live read that finds the bucket empty is not queued; it is answered with the record and a time to try again.

Collectors, in depth

A collector is a single static Go binary (its own public repo, elixir-mcp-collector) that runs anywhere with a static IP, a Mac in a closet, a Synology NAS at a cabin. What makes the fleet interesting:

  • Card identities. Every collector is named after a Clash Royale card, the operator's pick, and one collector per card, and appears publicly only by that card name; machine labels and IPs stay private. The signed-in service page shows each card's heartbeat and hourly fetch rate, and /api/public/status publishes the same fleet without a session.
  • The Family role. An operator whose collector is active holds the Family role while it stays active; there are no points, credits or bonus slots, and more collectors never buy more of the rate budget. See Roles.
  • Zero trust, zero AWS. A collector holds exactly two secrets: its operator's own IP-bound Clash Royale key, and a bearer token we issue. It speaks three HTTPS routes (config / lease / submit), no AWS credentials, no queues, nothing that touches the tenant. The server computes each job's CR path and stamps the collector's identity onto results itself, so impersonation is structurally impossible and collection changes never require a client update.
  • Check-ins, not polling. A collector asks the door for work and is told when to come back (next_check_in_s: at once while work remains, otherwise the seconds until that collector's own slot in a fifteen-second idle cycle). Every active collector owns an evenly spaced slot, so a fleet of five idles at one check-in every three seconds instead of all five arriving together after each scheduler tick. The door never holds a connection open. Every collector serves priority work first, so a live: true fetch is picked up by whichever machine checks in next; there is no separate live channel.
  • Self-update, server-authorized. The config endpoint names the one binary version and SHA-256 a collector may install, a compromised release page alone cannot push code to operators. An update failure never stops collection; a dev build never self-updates.
  • Misbehavior is bounded. At most two unsubmitted leases at a time; leases that expire unsubmitted redeliver their jobs and count against the collector, and a streak of ten drains it automatically: the door stops serving it work and tells its owner. Every payload is provenance-stamped with the collector that fetched it and kept in the archive for good, so a late-discovered bad actor's contributions can be traced to the payloads they came from.
  • Read audit. Every fresh battle-log poll with prior coverage is audited: if the payload's oldest battle was previously unseen, the rotating log may have rolled past something, recorded as a possible gap. The 24-hour gap count is public (capture_audit_24h in /api/public/status), so "no gaps" is a measurement, not a promise.

The timeline

Recording is pull; noticing is push. Everything you track appears on your timeline (elixir_timeline) while its notify switch is on. The timeline is synthesized when you read it, from the record and the per-subject ledger: the items that happened since your read pointer, newest first, each a sentence a person can read with its facts beside it, and one entry per subject summarizing the window. A person's entries are the players they track and the clans they added; an agent's is the clan it represents, with the members inside it. Facts with their windows, never judgments: what to do about a member quiet six days is deliberately your agent's call, not the service's, and nothing on the timeline announces the time, which is game_clock's job. The shape lives on The timeline tool.

The feedback loop

Feedback is a first-class product surface, not a mailbox. Agents file it mid-session with elixir_send_feedback (attributed to the account that connected: yours on your own connection, the agent's on an agent's); people file it from Settings, Ladder, Clan, the docs and an email's footer, and integrations over the JSON API. It is one record for all of Elixir, each item tagged with the part of Elixir it is about and what it points at (a call, an email, a clan action). Every item gets a maintainer response, sometimes written with help from AI agents, elixir_my_feedback pages through the full ledger, an account_feedback_responded item lands on the filer's timeline, and shipped fixes link the change. The same loop feeds the public Updates: contract versions are machine-readable (elixir_changelog), so an agent can ask what changed since its last session and discover capabilities that landed mid-session. Several shipped tools trace directly to agent-filed feedback.

The outbound relay

The VPC has no NAT. Cloud components cannot reach the internet at all, which is a security posture worth keeping. They reach S3 and nothing else, so anything bound for the internet leaves as an object in an outbox bucket: S3 notifies a queue, and a small non-VPC relay Lambda reads the object, acts on it and deletes it. The relay has five jobs, with deliberately different guarantees:

  • Mail, every email Elixir sends, over Amazon SES: retried hard and dead-lettered loudly.
  • Newsletter enrollment (Buttondown), for an account that opted in to product news: best-effort and idempotent, and an unsubscribed address is never re-subscribed. A new account's choice is the signup checkbox, which starts checked; an existing account's sign-in uses its saved preference.
  • Clan's model calls, made with the clan's own model key: requests and replies are sealed, short-lived objects, and a request is claimed before the provider is called, so a redelivery never spends the clan's tokens twice.
  • Discord posts, a clan's Actions and activity and a person's timeline cross-post, each to a sealed webhook: a post is claimed before Discord is called, and one whose outcome is unknown is never made again.
  • Discord sign-in: trading the code from linking or signing in with Discord, answered through a sealed, short-lived object. The doors cannot reach Discord themselves.

The relay also asks Cloudflare's Turnstile about a sign-in mail before sending it, for the same reason. Elixir's servers send nothing to analytics; the one analytics signal in mail is a Tinylytics pixel the reader's mail client loads.

Recording: record once, entitle many

  • Canonical battles. The same battle appears in every participant's battle log; we store it once under a content-derived ID (time + sorted participant tags + battle class). Later readings enrich the record (fill missing fields) but never overwrite it.
  • Payloads and receipts. Every API response is content-addressed and receipted with which collector fetched it and when. Projections (battles, readings, war tables) are derived from payloads and are rebuildable from source.
  • The S3 payload archive. New payload content is archived to S3 at admission (Hive-partitioned by endpoint, entity, and date; gzip JSON; lifecycle to Infrequent Access at 30 days) before the database commit, a committed row always has its S3 twin. S3 is the system of record for raw readings; Postgres keeps a payload's JSON only for a fetch a reader is waiting on (a live: true request, read within seconds of admission) and only for two hours; an hourly sweep nulls it and retires superseded rows only after verifying their archived copy, the database holds metadata and projections, S3 holds the bytes. Athena (via one Glue table with partition projection over every archived endpoint) and DuckDB both query the layout directly.
  • Readings and events. Daily profile readings feed trophy/donation timelines; diffs between polls emit events with honest time semantics (most things are "seen between polls", and the data says so).
  • War data. Clan-scoped, multi-tenant (every war table is keyed by the clan that saw it), points-vs-fame discipline enforced at write time, and one global 10:00 UTC grid, the same for every clan, files battles into seasons and weeks from their own timestamps. War facts are served as the game's weekly counters: a race's real daily rollover drifts per race and cannot be placed reliably at this scale, so no surface splits a member's week by war day (Time and clocks).

Scheduling: how often a player is fetched, and why

The official API is current-state only: a player's battle log holds roughly the last 30 battles and nothing older. Recording therefore means fetching often enough that no battle rolls off the log before a collector has seen it, while the whole fleet stays inside one shared, conservative API budget. For players the scheduler runs the session clock:

  • Thirty minutes while playing, doubling to a two-hour ceiling when not. A battle is about three minutes and the API's log holds 30, so a sitting fills it in about 90 minutes. A battle-log read that delivered battles is followed by another in 30 minutes; after a read that found nothing the wait doubles, and never passes two hours. The recorder measured its own loss against the game's lifetime battle counter before choosing the ceiling: about 4% of all battles, nearly all from long sittings that began inside a long wait, and the two-hour ceiling was the setting that ended it.
  • A reader cap keeps the players you ask about fresh. Asking about a player through any tool marks that player as read, and their battle log then stays within an hour for the next day, wherever the clock stands.
  • A fairness floor guarantees no battle log is forgotten. Whatever the clock says, every recorded player's battle log is fetched at least daily, even under a starved budget.
  • A failed read is retried in minutes. A fetch that errors (anything but a 404) is planned again 15 minutes later, then 30, then 60, and each retry is charged to the one budget like any other plan.

Profiles are read once a day, because the record keeps one reading per game day, and once after a session: a battle log that delivered battles asks for the profile unless one was read in the last eight hours. The one time-critical profile read, the pre-reset read of the weekly donation counter, is forced separately. The signed-in efficiency page shows what the schedule costs and what it loses, per day. Clan rosters follow the clan's own day, every 15 minutes while members of a tracked clan are in the game, coasting to hourly and then four-hourly as the roster's lastSeen stamps go quiet, and a few times a day for a clan read only because a recorded player is in it, and war-race polling reads the period type the API itself reports: tight on war days, relaxed on training days, with the war race also raising the battle-log cadence of members it names as having just battled. Because every cadence is a pure function of a player's state, subjects added together would otherwise fall due together forever; a stable per-subject phase offset de-phases such cohorts within a few cycles.

What this means when you read: every response carries the age of the polls it was built from (freshness_seconds in meta), elixir_coverage compares the lifetime battle counter against what was recorded over each reading interval and says so when they disagree, and the public health views report how many of the last day's polls found the log had already rolled: capture_audit_24h in /api/public/status, with no sign-in, and Status and data for the live strip. The signed-in service page shows the same to a signed-in account. When you need the state of play right now rather than the recorded history, live: true spends your live allowance on a fresh read instead of waiting for the schedule; live_fetch returns a raw read and stores nothing.

Access: entitlements, not permissions

  • A claim on your tag (trust-based; accounts open after email verification) gives your agent your full history, including battles recorded before you joined.
  • Universal game reads: all recorded game data, battles, profiles, timelines, clan rosters and wars, is readable by every active account, the same posture as the game's own public API (no membership gate on the clan tools). Your account data stays private.
  • The MCP door is OAuth 2.1 with rotating refresh tokens. cr:read is the baseline, while recordings, account preferences, and feedback each require their own write capability. The consent page names every requested capability, refresh never expands it, and an insufficient tool call is refused before it spends rate or daily quota.
  • Three principals, three doors. A person connects at /mcp; an agent at /a/<id>/mcp. Platform integrations use the JSON API at /api/v1. Grants are audience-bound to exactly one of them, so a credential presented at the wrong door is refused rather than quietly answering about the wrong subject, which matters because the three publish different tool surfaces and mean different things by "me". See Users, agents and integrations.
  • Service tokens are for headless principals, a Discord bot, a scheduled job, an app back end, and carry only the capabilities they were issued with. A human driving an agent signs in and consents instead, and never handles a raw key. The site uses cookie sessions; every tool call is audited against the credential that made it, with visible per-account quotas.

Honesty machinery

Coverage tools report recording start, per-endpoint freshness, and completeness ratios; every envelope carries a completeness_note when the record is measurably behind the profile's own battle counter; timelines disclose their epochs; inverted ranges refuse rather than pretending. Every windowed answer says which season it starts in and every season roll it crosses, every aggregate carries the control its number needs (the mode split, the level gap, the trophy floor, the clipped bucket), and a note fires only when the control detects a confound. The rule throughout: the system must never present a partial record as a complete one, or a pooled one as comparable.

Operations

CI-gated public repo; collectors self-update from released builds; alarms route to an operations queue drained daily; performance is censused continuously (every ingest message logs phase timings). The database is audited periodically and raw payloads live in an S3 archive, content-addressed and queryable with SQL over S3. Site visits are counted anonymously with Tinylytics; see Privacy.

The web API assembles feature-specific route modules behind shared session resolution. The account application separates Home, agents, connections, usage and feedback pages. The migration Lambda dispatches operational commands to separate modules; ordered migrations still run only through its deploy path. The shared metadata contract is checked when an envelope is built and after a tool returns. Successful UI journeys exercise the real API on scratch databases.

CI and local development use the same npm run verify gate: formatting, lint, dead-code analysis and workspace tests.

The code, and the family

Elixir MCP is built in the open:

  • jthingelstad/elixir-mcp, this service: recorder, MCP door, web app, and these pages (the docs you are reading are the source of record; docs/ENGINEERING.md holds the build invariants). Clan is part of the same application, account and deployment, with its management records in Elixir's private store; its role checks use the signed-in person's verified players.
  • jthingelstad/elixir-mcp-collector, the collector binary operators run.
  • jthingelstad/elixir-mcp-discord, the Discord agent any clan can run, POAP KINGS' included; each bot is an agent here, reading this service over MCP with its agent token.
  • jthingelstad/drop.poapkings.com, Elixir Drop, the elixir-cost learning game, which signs people in with Elixir and reads the game clock as an integration.

All of it orbits POAP KINGS, the clan.

‹ Run a collector Something wrong on this page?

Help for Architecture

How Elixir MCP is built: the collector fleet, the recording pipeline, the Postgres store, and the authenticated remote MCP server behind one hostname.

Read more

The page you are on rides along. Every item is answered, and the answer comes to your email and your timeline.