Building an agent
A runtime that acts for one clan, with its own door, key, identity map and feed.
An agent acts for a clan, not for a person. It is a separate principal that you own: its own identity, its own key and URL, its own timeline read pointer, its own feedback inbox. What it does never lands in your history and what you do never shows up as its. If you have not read Users, agents and integrations, start there.
Create one
Settings › Connections & agents › New agent, or POST /api/me/agents with a session.
Every agent you own has its own page, opened from its row in Settings ›
Connections & agents, at /settings/agents/<public_id>. It has five tabs:
Overview (key, last call, what is refusing it, the address to connect it
at, and links to its Timeline and the feedback it has filed), What it reads
(the clans and players it tracks), Usage (its share of your budget, its MCP
requests and account events), Connections (the clients connected as it) and
Settings. Pages that belong to a person (Verify, Profile) are not there. An agent spends your budget and your recording
slots, so your own Usage still counts it. New feedback is always filed as
you.
| Field | Rule |
|---|---|
name |
^[a-z0-9][a-z0-9-]{1,40}$; unique among your live agents; renamable later |
clan_tag |
a clan you have already added; the agent's subject |
scope |
optional OAuth scope string for the key; omitted means every capability |
The response (HTTP 201) carries the agent and its key once:
{ "agent": { "account_id": "…", "public_id": "k3f9x2mq7p", "role": "leader" },
"token": "svt_…", "note": "This token is shown once. Store it now." }
Only the key's SHA-256 is stored. The agent's role is your current role,
capped at leader, and it never sits above yours: when your role changes,
each agent's is clamped to the new one. It spends your daily call budget
and your live lane.
Every role may create agents (3 / 5 / 10 / 25; owner and admin unlimited);
over the cap the response is HTTP 400 {"error":"not_entitled","reason":"agent_limit","limit":N}.
Connect it
Its URL is on its page with a copy button:
https://elixir.poapkings.com/a/<public_id>/mcp
| Runtime | How |
|---|---|
| A human driving it (you, in Claude) | Add the URL as a connector and sign in. Only the agent's owner can consent; the grant is issued to the agent, so the session acts as the clan. |
| Headless (a Discord bot, a scheduled job) | Authorization: Bearer svt_… on every POST. No OAuth. |
A credential is bound to one door: the key at /mcp or at another agent's
URL answers 403 wrong_resource, and an OAuth token presented anywhere but
the door it was issued for answers 401 like an invalid one. A suspended
agent's key answers 401 too.
What initialize tells it
{ "serverInfo": { "name": "elixir-mcp", "version": "11.18.6+tools.…" },
"instructions": "YOU ACT FOR Tidewater #QRP2GV (47 members). Leadership: Birch Lantern (leader), … OMIT clan_tag to mean it. Pull clans_roster ONCE per run and reuse it… Nothing defaults to 'you': … pass on_behalf_of… call elixir_identify once. You already know 9 of them. …",
"_meta": { "elixir.poapkings.com/principal": {
"kind": "agent",
"subject": { "type": "clan", "tag": "#QRP2GV", "name": "Tidewater", "members": 47 } } } }
Use _meta to assert the boot: kind must be agent and subject must
not be null. The instructions are for the model and change without notice.
The roster is deliberately absent because the instructions are held until
reconnect; call clans_roster once per run.
An agent's tools/list has 45 tools: everything except elixir_my_players
and clans_my_view, which need a self. Omit clan_tag anywhere and it means the agent's clan.
What an agent tracks
An agent can track clans and players as well: a rival clan it is
asked to watch, the other clans of a family, a player worth following.
elixir_track_clan and elixir_track_player work for it the way they work
for you, with an agent's meaning. Its players are watching, never
primary, alt or friend, because those say "me". Every clan and player
it tracks spends your recording slots: they are one pool across you and
all your agents, a subject counts once however many of you track it, and a
clan counts once at the widest scope any of you gives it. So making agents
never adds slots. Only active agents are in the pool: a suspended
agent's players and clans take none of your slots and are recorded only if
you or someone else still tracks them, so the record has a gap for the
ones only it tracks until you resume it. Resuming is refused while it would
take you past a slot ceiling, and the refusal says how many to free.
One of its clans is the clan it acts for, its default wherever
clan_tag is omitted. It cannot remove that clan, or its last one; you can
make another of its clans the one it acts for from its pages
(What it reads), and then remove the old one. What it tracks is on
What it reads, and every change to it on Usage, under its account
events.
An agent never reads a person's view in Clan: clans_my_view
answers only a person's own connection
(Your own view). An agent's one read
of Clan is its assigned clan's war intent, with an approved grant
(Agent context).
Knowing which human is asking
MCP carries no per-request user identity, so the agent supplies one.
on_behalf_of is an opaque string (≤200 chars) in your own id space:
discord:1234, telegram:…, a session id. Pass it on every call that could
mean "me". The first time an id is unknown, the subject-resolving tools
answer no_subject (there is nothing to answer about; an agent has no self
to fall back on):
{ "error": { "code": "no_subject",
"message": "No player is mapped to discord:1234 yet.",
"hint": "Ask who they are in the clan, then elixir_identify({ external_id, player_tag }) once to remember it. Or pass player_tag explicitly." } }
A person can connect their Discord account to Elixir under
Settings › Connections & agents, proved by Discord's own sign-in. Then discord:<their Discord user id> resolves by itself: to their primary player, or else an alt,
that is a current member of a clan on this connection. Every response
to a call that carries on_behalf_of says who it named and how:
meta.asker is { external_id, player_tag, via, verified }, where
via is discord_link (the person connected it), agent_mapping
(your own elixir_identify) or null (nobody yet), and verified says
whether the player's claim is proven in game. A discord: id nobody has
connected or mapped is no_subject, whose hint adds that the person can
connect Discord in their Settings.
For anyone else, map the id once with elixir_identify (scope account:write). The tag must be a
current member of a clan on this connection; anything else is
not_entitled, because a wrong mapping answers confidently about the wrong
person for good. The mapping is per agent account, invisible to every other
account, and confers nothing: recorded data is readable by every account
anyway. elixir_my_identities lists what the agent has learned. Both tools
are agent-only: a personal connection has one self, its primary player, and
ignores on_behalf_of.
A complete exchange
Pass the asker's display name beside the id (display_name) and
the refusal does the roster comparison for you: error.candidates[] lists
the clan members whose whole name matches, case and spacing ignored,
and never a partial match. One candidate is one elixir_identify call;
zero or several is a question to the person.
member Birch Lantern (discord:1234): how am I doing?
→ players_summary({ on_behalf_of: "discord:1234", display_name: "Birch Lantern" })
← { error: { code: "no_subject", class: "subject", message: "No player is mapped to discord:1234 yet.",
candidates: [ { player_tag: "#PYLQ88", name: "Birch Lantern", clan_tag: "#QRP2GV", role: "coLeader" } ],
hint: "candidates[] holds the one clan member whose whole name is 'Birch Lantern': elixir_identify({ external_id: \"discord:1234\", player_tag: \"#PYLQ88\" }) once, say so in a line, and answer." } }
→ elixir_identify({ external_id: "discord:1234", player_tag: "#PYLQ88" })
← { external_id: "discord:1234", player_tag: "#PYLQ88", name: "Birch Lantern", clan_tag: "#QRP2GV",
notes: ["Pass this external_id as on_behalf_of from now on; omit player_tag and it means them.", …],
docs: "agents#knowing-which-human-is-asking" }
→ players_summary({ on_behalf_of: "discord:1234" })
← { player_tag: "#PYLQ88", name: "Birch Lantern", trophies: …, last_30_days: { battles: 41, wins: 24, losses: 17, draws: 0, win_rate: 0.585, … }, top_deck: …,
applied: { window: { from: "…", to: null, source: "fixed", days: 30 } }, notes: […], docs: "recording#completeness",
meta: { freshness_seconds: 27, source_polls: { player_battlelog: { observed_at: "…", freshness_seconds: 27 } }, … } }
Without display_name, or when no whole name matches (candidates: []),
ask which player in the clan they are, resolve the answer with
players_search, and identify once. Every later call from discord:1234
resolves with no lookup. Always read
meta.freshness_seconds before you quote a number; asking about a player
also keeps their battle log polled hourly for the next day, so a second
question is fresher than the first.
Consuming the timeline
elixir_timeline returns the items that happened since your read pointer,
newest first, plus one entry per subject. For an agent the subject is the clan
it represents: its members' sessions and moments, joins and departures, the
war moments and the presence crossings arrive as items, and the entry
summarizes the window. A consumer names its own pointer with reader:
a short name (editor, poap-kings-discord), and from then on
an omitted from reads since that reader's pointer, mark_read moves it
and read_to reports it, while other readers on the same account and the
account's own unnamed pointer are untouched. A headless runtime reads and
marks as itself:
// The first read with a new reader name covers the last 24 hours; every
// later one starts where this reader's last mark_read ended.
const page = await call("elixir_timeline", {
reader: "editor", // this consumer's own pointer
sections: ["roster", "war", "presence"], // optional: trim items and entries
skip_empty: true, // optional: an empty window costs one check
});
if (page.timeline.length === 0) return; // nothing to consider
for (const item of page.timeline) consider(item); // item.text is the sentence; item.facts the numbers
// To post: only item.publishable; once per item.id, edited on a higher
// item.revision; the time as <t:${item.at_unix}:f>, never in the prose.
// For the story behind one: call each item.follow[] { tool, args } as given.
for (const entry of page.entries) context(entry); // the window's shape per subject
// read_to is now the window's end; nothing to persist locally.
With skip_empty: true a window that holds nothing for this reader is
answered without reading the subjects (entries_skipped is true and
entries is empty), so a poll every few minutes costs little. Drop it on
the read that wants the entries of a quiet window.
A runtime that keeps its own cursor still can (pass from, mark_read: false), but then meta.timeline_pending cannot help it: that hint counts
admissions since the OLDEST named pointer on the account, or the account's
own when no reader has ever marked, so a reader that marks sees it fall to
0 after its read and can skip the next read when it is 0 on any call it was
making anyway.
A reader that pulls a day's story rather than polling ("what happened
yesterday") passes from and to without a reader: that read is by
when things happened (basis: occurred), keeps every pointer, and
leaves out the earlier games the record learned in the window. A poller's
read is by when the record learned them, so nothing learned late is
skipped, and an item that happened before from says late: true
(Two kinds of window).
The item and entry shapes are on The timeline tool, with the
facts keys each kind carries and its version (a battle_session has
battles, won, lost, by_mode, trophy_net and
trophy_net_peak (the session's Trophy Road net), trophy_net_by_mode;
a session_standout adds crossed, newly and crossings, the value at
each rung's battle; a ranked_promotion has from, to and their names and, when the
record holds it, the promoting battle under promoted_by; a clan's
member_left has the departing member's player_tag, name and
role_at_departure). Branch on kind and facts; text is for the
person: one time-free sentence naming everyone by their in-game name, so
it can be published as it is. A read serves each story once, about every
subject in subjects; publishable says whether it may leave Elixir.
meta.timeline_pending on any response counts subjects of yours the
recorder has admitted something for since the oldest named reader's
pointer (or the account's), which is only meaningful if something marks.
Polling has a price. A loop that calls elixir_timeline and
elixir_my_feedback every 300 seconds makes about 576 calls a day, well over
a member's whole budget of 500 tool calls. Two rules keep it cheap: read
elixir_my_feedback only when meta.feedback_responses_pending > 0, and
meta.timeline_pending and meta.feedback_responses_pending ride every
response, including elixir_timeline itself and game_clock, so any call you
were making anyway tells you whether the next one is worth it. A routine
that has nothing else to do can read the feed on a timer, but a few times a
day is plenty: the timeline covers the whole window, so a longer window
costs the same one call. The feed never announces the time: read
game_clock once and take war_day_closes_at and next_war_day_opens_at
from it if your routine cares about war at all.
A routine that runs once a day reads its clan's timeline, drills with
clans_standings or battles_trends when something moved, and, if it
schedules itself before a close from game_clock, checks
war_current.decks_today.untouched then, unless race_finished_at is set.
Facts in, judgment in your code.
Key lifecycle
| Action | Where | Effect |
|---|---|---|
| Rotate | its Settings tab, Issue a new key, or POST /api/me/principals/rotate |
one transaction: every live key revoked, a new one issued with the same name and scope. public_id (the URL), identities and its timeline read pointers survive. |
| Revoke | its Settings tab, Revoke key | the key stops immediately; nothing to restore. Issue a new one with Rotate when ready: it takes the name and scope of the most recent key, and is refused (409 name_taken) only if another of your live agents has taken that name since. |
| Suspend / Resume | its Settings tab | status: disabled; the same key reads as invalid until resumed. Its players and clans leave your slots and stop recording unless someone else tracks them. Reversible: resuming takes the slots back (409 quota_exceeded when they are not free) and records them again. |
| Remove / Restore | a suspended agent's Settings tab | takes it out of the agents table into a folded Removed agents list on Settings › Connections & agents. Nothing is deleted: it stays suspended, keeps its address, keys and history, and still opens at its own page. Restore puts it back among your suspended agents; Resume restores it too. |
| Rename | its Settings tab | changes the name; must stay unique among your live agents |
| Delete | – | there is no delete. Suspend is the reversible stop, revoke the irreversible one, and Remove only hides a suspended agent. |
An agent acts only while its owner has access. If your access is
withdrawn, every agent you own stops with it: its keys and the apps
connected as it read as invalid (principal_suspended on its page) until
your access returns, and then the same keys work again.
Knowing whether it is working
A refused key never reaches the call log, so a runtime still presenting a
rotated key produces silence, not errors. The agent's page shows, per key,
last_used_at and the current key has never been used; per source
address, refusals in the last seven days with the reason (revoked_key,
principal_suspended, expired, wrong_door, wrong_resource); and calls_7d,
last_seen (address, country, client name) and timeline_pending. Your own
Usage breaks the agents' calls out of your daily budget.
Feedback from an agent
elixir_send_feedback filed through an agent is attributed to the agent, answered
by the maintainer (sometimes with help from AI agents), and delivered back as an account_feedback_responded item
on the agent's timeline plus meta.feedback_responses_pending on the agent's
own responses. Agents are
expected to file friction on their own judgment.
When a person on your surface says something worth passing on (a thumbs
down on an answer, a "that's wrong"), file it with on_behalf_of set to
their surface id, as on any call. The report is kept as theirs, relayed by
the agent, and names their player when the agent has mapped them with
elixir_identify; the answer still comes to the agent, which decides how
to tell them.