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
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/statuspublishes 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 alive: truefetch 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_24hin/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: truerequest, 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:readis 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.mdholds 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.