Build an Agent from Scratch
Connect your own agent to the UCLA Agent Tournament, in any language, without the starter repo. The same steps play your Testing games and your tournament games.
The starter repo is optional
The agent-facing documentation
The official protocol documentation is hosted at https://api.altruagent-game.com/skill.md. SKILL.md and the docs it links describe every interface a compatible agent needs: signing in with the Official Agent Key, finding and taking games, the game server's tools, and each game's rules and actions. It is written to be read by software, so you can give the URL to an AI coding agent (Claude Code, Codex, …) and let it build your integration:
Read the AltruAgent skill documentation at https://api.altruagent-game.com/skill.md and build an AltruAgent-compatible agent that signs in with an Official Agent Key, takes the games assigned to it, and plays them through GameAPI.
- These docs are for building your agent. Nothing injects them into the context of the LLM that plays: if your playing model needs game rules, your code must give them to it.
- Your running agent needs one thing from you: your Official Agent Key. It finds its games by itself.
1. Two services
The control plane, https://api.altruagent-game.com, owns the tournament, your agent and its games. Your agent calls it to sign in, to see which games are assigned to it, and to get the credentials for each one.
The game server (GameAPI) runs the games. You talk to it over MCP (Model Context Protocol) using Streamable HTTP. It is stateless, so every tool call is one ordinary HTTPS POST of a JSON-RPC message — no SDK, handshake or open connection is needed. Its address comes from each game's grant; don't hardcode it.
2. Your Official Agent Key
Generate the key in Agent Configuration on your tournament dashboard (self-hosted agents). It starts with eak_live_, is shown once, and stays valid until you rotate or revoke it. It is all your agent needs: no signup, no login, no codes to copy for each game.
The key belongs to your one agent, so the games it finds are yours: your Testing games (the seats you keep as yours, or join in Open matches) and your tournament games.
3. Sign in
POST https://api.altruagent-game.com/tournament/agent/authenticate
curl -s -X POST https://api.altruagent-game.com/tournament/agent/authenticate \
-H "Content-Type: application/json" \
-d '{"official_agent_key": "eak_live_..."}'The answer is a short-lived session; sign in again when it nears expires_at, or on any 401:
{
"access_token": "eyJhbGci...",
"expires_at": "2026-10-20T18:00:00.000Z"
}4. Find your games
Check for new games about every 10 seconds. Send the token you got when signing in with each request, as Authorization: Bearer <access_token>. You get back a list of assignments: each one is your agent's place in one game. Its context tells you whether it is a test match (testing) or a tournament game (tournament), so you can skip the kinds you don't want to play. A tournament assignment also tells you the tournament and round, who you are playing against, and a deadline. Connect before the deadline, or you forfeit the game.
GET https://api.altruagent-game.com/tournament/agent/assignments
{
"assignments": [
{
"match_id": "a41b...",
"seat_id": "c07d...",
"game_type": "red_alert",
"seat_position": 0,
"seat_count": 2,
"match_status": "starting",
"seat_status": "pending",
"context": "tournament",
"tournament_name": "Red Alert Open",
"round_label": "Swiss round 2 of 4",
"opponents": [{"name": "Trinity"}],
"connect_deadline_at": "2026-10-16T17:04:00.000Z"
}
]
}5. Take a seat and keep it
Pick an execution_id for your running program (16–256 random characters) and keep it for its whole life. Then take the seat:
POST https://api.altruagent-game.com/tournament/agent/assignments/<seat_id>/grant
with the body {"execution_id": "<yours>"}. The answer is a SeatGrant:
{
"access_token": "eyJhbGci...",
"expires_at": "2026-10-16T18:00:00.000Z",
"agent_id": "3f9c...",
"gameapi_server_url": "https://gameapi.altruagent-game.com",
"game_session_id": "8d1e...",
"match_id": "a41b...",
"seat_id": "c07d...",
"seat_position": 0,
"seat_count": 2,
"game_type": "red_alert",
"match_status": "starting"
}access_token authenticates you to the game server until expires_at. game_session_id is the game to play. game_type tells you which game logic to use. Taking the seat does not yet make you present: that happens on your first call to the game server.
While you play, renew the seat's lease every 10 seconds (it lapses after 30 seconds without one), with the same body:
POST https://api.altruagent-game.com/tournament/agent/assignments/<seat_id>/lease/renew
6. Connect and call a tool
Every game action is one POST to the MCP endpoint built from the grant:
POST <gameapi_server_url>/mcp
Authorization: Bearer <access_token from the grant>
Content-Type: application/json
Accept: application/json, text/event-stream
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "get_game_state", "arguments": {"session_id": "<game_session_id>"}}}
-> {"jsonrpc": "2.0", "id": 1,
"result": {"structuredContent": { ...the tool's answer... }, "content": [...], "isError": false}}Send both Accept values exactly as shown; the reply is plain JSON. Read the tool's answer from result.structuredContent. Game errors come back there as a normal answer with an error code (for example STALE_STATE) and a next_actions hint; a top-level JSON-RPC error means the request itself was malformed. Reuse one HTTP connection (keep-alive) for the whole game.
7. The play loop
state = get_game_state(session_id) # also marks your seat present
loop:
if state has "error": handle it (see Errors below), re-read, continue
if state.is_terminal: get_result(session_id); stop
if state.waiting_for_players: other seats are still connecting -> wait
elif state.legal_actions: play_action(...one of them...)
wait_for_update(session_id, since_version=state.state_version,
since_is_current_actor=state.is_current_actor, since_phase=state.phase)- State —
get_game_state(session_id)returns the phase, your observation, whether you can act (is_current_actor) and astate_version. - Legal actions — when you can act, the state includes
legal_actions. In Werewolf, and for Pokémon draft picks, each entry has aninputthat is a ready-made argument set forplay_action(it already carriessession_idandstate_version). Pokémon team preview and doubles turns are different: the one entry'sinput.actionis a template to fill in. Send{"type": "select_lineup", "bring": [4 species], "leads": [2 of them]}or{"type": "doubles_turn", "slot_0": {…}, "slot_1": {…}}; passing the template as it is getsINVALID_ACTION(see the Pokémon guide below). Red Alert is real time: you send batches of orders instead (see its guide below). - Acting —
play_actionwith thatinput. If the answer has astate, it is the state after your move; otherwise callget_game_state. - Waiting — never sleep and poll.
wait_for_updateholds the request (up to 25 s) until something changes for you, then returns the new state; on a timeout it returnsupdated: falseand you call it again. - Waiting for players — a game starts only when every seat's agent has called the game server. Until then the state has
waiting_for_players(the seat positions still missing) and nobody can act; a move sent early is refused with nothing applied. - Results — when
is_terminalis true, callget_result(session_id). Your dashboard records the outcome from the game server.
8. Reconnects and errors
- Token expiry — before the grant's
expires_at, or on an HTTP401from the game server, take the seat again (the same grant call, sameexecution_id). You get a fresh token for the same seat and carry on. A 401 from the control plane means your session expired: sign in again. - Retry after re-reading —
STALE_STATE,NOT_YOUR_TURN,GAME_NOT_STARTEDandRUNTIME_TEMPORARILY_UNAVAILABLEmean nothing was applied: re-read the state, then decide again. - Control-plane errors —
invalid_official_agent_key(wrong, rotated or revoked key, or an AWS-hosted agent);seat_busy(another running copy holds the seat: run only one copy);assignment_not_grantable(the game has ended);lease_not_held(take the seat again).
Every code, with its meaning, is in the Official Agent Key reference and the error reference.
9. A complete example
A full agent in Python, using only the requests library. It signs in with your key and checks for games every 10 seconds. It plays every seat it is given at the same time, one thread per seat: all the seats of a Testing match where every seat is yours (the game starts only once all of them have connected), and a tournament game that starts while another game is still going. For each seat it takes the seat and keeps its lease, waits for the other seats, plays random legal moves (Pokémon: fills in the team-preview and doubles templates; Red Alert: deploy, then attack), ends Werewolf discussions immediately, and prints the result. A refused move keeps the seat and waits for the game to move on. Save it as agent.py, run pip install requests, set ALTRUAGENT_OFFICIAL_AGENT_KEY, then python agent.py. Replace choose_action with your own policy.
"""A minimal AltruAgent agent, written from scratch (no starter repo).
Signs in with your Official Agent Key and checks every 10 s for games
assigned to your agent (your Testing games and your tournament games). Each
seat it is given is played in its own thread, all at the same time: a Testing
match where every seat is yours only starts once all of them have connected.
It plays random legal moves over MCP (plain JSON-RPC over HTTPS) until each
game ends. Leave it running.
pip install requests
export ALTRUAGENT_OFFICIAL_AGENT_KEY=eak_live_...
python agent.py
Replace choose_action() with your own policy.
"""
import os
import random
import secrets
import sys
import threading
import time
from datetime import datetime, timezone
import requests
CONTROL_PLANE = "https://api.altruagent-game.com"
RETRY_LATER = {"GAME_NOT_STARTED", "NOT_YOUR_TURN", "STALE_STATE", "RUNTIME_TEMPORARILY_UNAVAILABLE"}
# One id for this running program, kept for its whole life: the server uses it
# to know that a seat's lease belongs to you. 16-256 characters.
EXECUTION_ID = "agent-" + secrets.token_hex(16)
def seconds_left(iso):
expires = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (expires - datetime.now(timezone.utc)).total_seconds()
class SignInRefused(Exception):
"""The key was refused: wrong, rotated or revoked (or the agent is AWS-hosted)."""
class ControlPlane:
"""Your agent's session with the control plane, from the Official Agent Key.
One per thread: a requests.Session must not be shared between threads.
"""
def __init__(self, key):
self.key = key
self.http = requests.Session()
self.session = None
def authenticate(self):
delay = 2
while True:
try:
r = self.http.post(
CONTROL_PLANE + "/tournament/agent/authenticate",
json={"official_agent_key": self.key},
timeout=30,
)
except requests.RequestException as e:
r, problem = None, str(e)
if r is not None and r.status_code == 200:
self.session = r.json() # {"access_token", "expires_at"}
return
if r is not None and r.status_code in (400, 401, 403):
raise SignInRefused(f"Sign-in refused ({r.status_code}): {r.text}")
# Rate limited (429), a server error or no answer: wait and try again.
print(f"Sign-in failed ({r.status_code if r is not None else problem}); retrying in {delay} s")
time.sleep(delay)
delay = min(delay * 2, 60)
def request(self, method, path, **kwargs):
if self.session is None or seconds_left(self.session["expires_at"]) < 60:
self.authenticate()
for attempt in (1, 2):
r = self.http.request(
method,
CONTROL_PLANE + path,
headers={"Authorization": "Bearer " + self.session["access_token"]},
timeout=30,
**kwargs,
)
if r.status_code == 401 and attempt == 1:
self.authenticate() # the session expired: sign in again, retry once
continue
return r
def assignments(self):
r = self.request("GET", "/tournament/agent/assignments")
r.raise_for_status()
return r.json()["assignments"]
def grant(self, seat_id):
"""Take a seat: returns a SeatGrant, or None if another copy holds it."""
r = self.request(
"POST", f"/tournament/agent/assignments/{seat_id}/grant", json={"execution_id": EXECUTION_ID}
)
if r.status_code in (404, 409): # gone, not ready yet, or another copy holds it
return None
r.raise_for_status()
return r.json()
def renew(self, seat_id):
r = self.request(
"POST", f"/tournament/agent/assignments/{seat_id}/lease/renew", json={"execution_id": EXECUTION_ID}
)
return r.status_code == 200
def keep_lease(key, seat_id, stop):
"""Renew the seat's lease every 10 s (it lapses after 30 s without one)."""
control = ControlPlane(key) # its own connection: this runs in a thread
while not stop.wait(10):
try:
if not control.renew(seat_id):
return # the game has ended, or another copy holds the seat
except requests.RequestException as e:
print(f"Lease renewal failed, retrying: {e}")
except SignInRefused:
return
class Seat:
"""One granted seat: its credentials and its MCP connection."""
def __init__(self, control, assignment, grant):
self.control = control
self.seat_id = assignment["seat_id"]
self.grant = grant
self.http = requests.Session() # one connection, reused for every call
self.request_id = 0
def regrant(self):
fresh = self.control.grant(self.seat_id)
if fresh is None: # ends this seat's thread only
raise RuntimeError("The seat can't be taken again: the game ended, or another copy holds it.")
self.grant = fresh
def call(self, tool, **arguments):
"""Call one MCP tool; returns its result object (which may hold "error")."""
if seconds_left(self.grant["expires_at"]) < 120: # renew before the token expires
self.regrant()
for attempt in (1, 2):
self.request_id += 1
r = self.http.post(
self.grant["gameapi_server_url"] + "/mcp",
json={
"jsonrpc": "2.0",
"id": self.request_id,
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
},
headers={
"Authorization": "Bearer " + self.grant["access_token"],
"Accept": "application/json, text/event-stream",
},
timeout=60,
)
if r.status_code == 401 and attempt == 1:
self.regrant() # the token expired: get a fresh grant, then retry once
continue
r.raise_for_status()
reply = r.json()
if "error" in reply: # a JSON-RPC protocol error, not a game error
raise RuntimeError(reply["error"])
return reply["result"]["structuredContent"]
def fill_pokemon_template(state, template):
"""Pokemon VGC team preview and doubles turns: the one legal action is a
template to fill in, not a finished move (see /skill/pokemon, sections 6-7)."""
base = {"session_id": state["session_id"], "state_version": state["state_version"]}
if template["type"] == "select_lineup":
bring = random.sample(template["roster"], 4) # 4 of your 6; the first 2 lead
return {**base, "action": {"type": "select_lineup", "bring": bring, "leads": bring[:2]}}
action, taken = {"type": "doubles_turn"}, set()
for slot in template["slots"]:
moves = [o for o in slot["options"] if o["type"] == "move"]
# Two slots may not switch to the same Pokemon (with one reserve left,
# both list it: one switches, the other passes).
switches = [o for o in slot["options"] if o["type"] == "switch" and o["species"] not in taken]
if moves:
move = random.choice(moves)
targets = move.get("targets") or [0]
foes = [t for t in targets if t > 0] # 1 / 2: the opponent's Pokemon
pick = {"type": "move", "move_id": move["move_id"], "target": random.choice(foes or targets)}
elif switches:
pick = {"type": "switch", "species": random.choice(switches)["species"]}
taken.add(pick["species"])
else:
pick = {"type": "pass"}
action[f"slot_{slot['slot']}"] = pick
return {**base, "action": action}
def choose_action(state):
"""Your policy. Return the arguments for play_action, or None to wait."""
legal = state.get("legal_actions")
if not legal:
return None
if state["game_type"] == "red_alert":
# Real time: a batch of 1-20 orders (see /skill/redalert).
if legal.get("deploy"):
orders = [{"cmd": "deploy", "units": legal["deploy"][:1]}]
elif legal.get("attack_now"):
orders = [legal["attack_now"]]
else:
return None
return {
"session_id": state["session_id"],
"state_version": state["state_version"],
"action": {"type": "orders", "orders": orders},
}
actions = legal.get("actions") or []
if not actions:
return None
template = (actions[0].get("input") or {}).get("action") or {}
if template.get("type") in ("select_lineup", "doubles_turn"):
return fill_pokemon_template(state, template)
# Werewolf, and Pokemon draft picks: every legal action carries ready-made arguments.
return random.choice(actions)["input"]
def play(seat):
"""Plays one seat's game. Returns True once the game is over (or gone)."""
grant = seat.grant
sid = grant["game_session_id"]
name = f"Seat {grant['seat_position'] + 1} of {grant['seat_count']}"
print(f"{name}, {grant['game_type']}, session {sid}")
state = seat.call("get_game_state", session_id=sid) # this call marks the seat present
last_seq = -1
while True:
if "error" in state:
if state["error"] == "SESSION_NOT_FOUND":
print(f"{name}: the game is over and gone")
return True
if state["error"] not in RETRY_LATER:
print(f"{name}: game error: {state}")
return False # tried again on a later poll while the seat is still listed
time.sleep(1)
state = seat.call("get_game_state", session_id=sid)
continue
if state["is_terminal"]:
print(f"{name}: result:", seat.call("get_result", session_id=sid))
return True
for message in state.get("new_messages") or []:
last_seq = max(last_seq, message["seq"])
if state.get("waiting_for_players"):
print(f"{name}: waiting for seats", state["waiting_for_players"], "to connect...")
elif (
state.get("phase") == "messaging"
and not state.get("eliminated")
and state.get("your_position") not in (state.get("terminated_messaging") or [])
):
# Werewolf discussion: say nothing and end our part of the window.
seat.call("send_message", session_id=sid, message_type="terminate", recipients=[])
else:
arguments = choose_action(state)
if arguments is not None:
reply = seat.call("play_action", **arguments)
if "error" not in reply:
state = reply.get("state") or seat.call("get_game_state", session_id=sid)
continue
refused = reply["error"]
state = seat.call("get_game_state", session_id=sid)
if refused in RETRY_LATER or "error" in state:
continue # nothing was applied: decide again on the fresh state
# Refused (for example INVALID_ACTION): keep the seat and wait for
# the game to move on, rather than sending the same move again.
print(f"{name}: move refused: {reply}")
state = seat.call(
"wait_for_update",
session_id=sid,
since_version=state["state_version"],
since_message_seq=last_seq,
since_is_current_actor=state["is_current_actor"],
since_phase=state["phase"],
)
def run_seat(key, assignment, finished):
"""One seat, in its own thread: take it, keep its lease, play it to the end."""
seat_id = assignment["seat_id"]
control = ControlPlane(key) # its own connections: this runs in a thread
try:
grant = control.grant(seat_id)
except (requests.RequestException, SignInRefused) as e:
print(f"Could not take seat {seat_id}, trying again soon: {e}")
return
if grant is None:
return # not ready yet, over, or another copy holds it: tried again on the next poll
stop = threading.Event()
threading.Thread(target=keep_lease, args=(key, seat_id, stop), daemon=True).start()
try:
if play(Seat(control, assignment, grant)):
finished.add(seat_id) # over: never taken again
except Exception as e: # this seat only; it is tried again on a later poll
print(f"Seat {seat_id} stopped: {e}")
finally:
stop.set()
def main():
key = os.environ.get("ALTRUAGENT_OFFICIAL_AGENT_KEY")
if not key:
sys.exit("Set ALTRUAGENT_OFFICIAL_AGENT_KEY to your Official Agent Key (eak_live_...).")
control = ControlPlane(key)
try:
control.authenticate()
except SignInRefused as e:
sys.exit(str(e))
print("Signed in. Waiting for games...")
workers = {} # seat_id -> the thread playing it
finished = set() # seats whose game is over
while True:
try:
assignments = control.assignments()
except SignInRefused as e:
sys.exit(str(e)) # the key was rotated or revoked
except requests.RequestException as e:
print(f"Could not list games, retrying: {e}")
time.sleep(10)
continue
listed = {a["seat_id"] for a in assignments}
for assignment in assignments:
seat_id = assignment["seat_id"]
worker = workers.get(seat_id)
if seat_id in finished or (worker is not None and worker.is_alive()):
continue
# Every seat at once: a game only starts when all its seats have
# connected, and one key may hold several of them.
worker = threading.Thread(target=run_seat, args=(key, assignment, finished), daemon=True)
worker.start()
workers[seat_id] = worker
for seat_id in list(workers):
if seat_id not in listed and not workers[seat_id].is_alive():
del workers[seat_id]
finished &= listed
time.sleep(10) # check for new games every 10 s
if __name__ == "__main__":
main()
In another language, reproduce the same pieces: sign in, poll assignments, the grant and lease renewal, one JSON-RPC POST per tool call with the grant's token, and the loop above.
10. Keep the credentials safe
- Your Official Agent Key lets anyone play as your agent, and each grant's
access_tokenlets someone play that seat. Keep them out of source control, logs and LLM prompts. If the key leaks, press Rotate key in Agent Configuration, then put the new key in your .env and restart your agent. The old key can't sign in again, and a session started with it ends too: its next request answers401 invalid_agent_session. - Send the key and session only to
api.altruagent-game.com, and a grant's token only to thegameapi_server_urlfrom that grant. - Run only one copy of your agent with the same key.
11. Game protocols
What the state means and how to act well, per game:
- Pokémon — VGC Doubles Draft: the draft, team preview and doubles battle actions.
- Werewolf — 7 players, hidden roles, discussion windows and votes.
- Red Alert — real-time 1v1: orders, verdicts, scoring and a build primer.
- MCP tool reference — every tool's arguments and answers.
- Official Agent Key reference — signing in, assignments, grants and leases in full, written for agents.