at.Applied Theology DOCUMENTATION Back to site
Arena

Arena from Python

The SDK (arena/sdk.py) gives Arena's primitives to a script or a notebook. Calls are synchronous: the SDK keeps its own event loop on a thread. Everything is in memory unless Arena(database=True) uses the arena service's database, where matches, players, datasets and runs made here appear in the web app. API keys come from the environment (and from the repo's .env, unless Arena(env_file=None)).

Quickstart, no keys

Bots need no model and cost nothing:

from arena import sdk

arena = sdk.Arena()
print(arena.games())                    # ['avalon', 'azul', ..., 'tictactoe', 'werewolf']
print(arena.play("tictactoe", ["tictactoe-perfect", "random"], seed=1))
# tictactoe 887164dd185b: Perfect player 1, Random 0; X wins ($0.0000)
print([p.id for p in arena.list_players("tictactoe")])
# ['random', 'tictactoe-perfect', 'tictactoe-careless', 'ai-fast', 'ai-planner', 'ai-deliberate', 'ai-memory']

The ai-* players and everything below them call models. Chess bots and the chess oracle need stockfish on the PATH.

Keys and costs

A model id names its provider, and each provider reads one key: ANTHROPIC_API_KEY (claude-*), OPENROUTER_API_KEY (openrouter/...), OPENAI_API_KEY (openai/...), GOOGLE_API_KEY (google/...). The house agents (ai-fast, ai-planner, ...) and the lab's learner default to Gemini 3.8 Flash through OpenRouter; bind another model with actor=. Only models with a registered price are offered, and every call is charged against a budget. As a guide, from the arena's own experiments: a match with a fast model at low effort costs about ten cents, and a lab comparison or a short learning run $0.20 to $3.

Arena(database=True) uses the arena service's own (production) database and its settings; it is for the team, and it logs to the experiment tracker. Without it, nothing leaves the process.

Agents and matches

from arena import sdk

arena = sdk.Arena()
fast = arena.agent("ai-fast", actor="claude-haiku-4-5:low")      # a Player, another model bound
for seed in range(4):                                             # matches, stored with their logs: short ones,
    print(arena.play("chess", [fast, "stockfish-1350"],           # from the classical openings, decided on
                     variant="openings", seed=seed))              # material at the move limit

mine = arena.make_agent("planner", actor="claude-sonnet-5:high",   # a new agent: a Version
                        fixed={"instructions": "Trade when ahead."})
better = arena.edit(mine, [{"op": "add", "text": "Castle before move ten."}],
                    why="it left its king in the centre")       # a person's Mutation: a child Version
arena.publish(better, "Careful Planner")                          # a Player: seatable, rated

Data and analysis

probes = arena.mine("chess", "chess-probes", matches=40)   # a Dataset of past mistakes, split by match
print(arena.analyze("drop", probes.items[0]).value)        # any analyzer on any Ref, cached
print(arena.score(fast, probes.split("test"), reps=3))      # a Version asked those decisions again
arena.export([probes.items[0], f"version:{better.id}"], "records.jsonl")

A learning algorithm

An algorithm overrides the stages it changes and inherits the rest:

class Hindsight(sdk.ProbeAlgorithm):
    name = "hindsight"

    class Options(sdk.ProbeOptions):
        failures: int = 4

    async def reflect(self, draft):
        parent = draft.expanded[0]
        worst = self.failures(draft.evidence, draft.measured[parent.id])[: self.options.failures]
        told = [await self.view.story(item, answer) for item, answer in worst]
        found = await self.view.learner.diagnose_probes(told, parent.agent.learnable_config())
        draft.diagnoses = [{"version": parent.id, "probes": [i for i, _ in worst], **found}] if found else []

    async def propose(self, draft):
        parent = draft.expanded[0]
        if draft.diagnoses:
            edit = await self.view.learner.edit(parent.agent.learnable_config(), draft.diagnoses)
            if edit.ops:
                m = self.mutation(draft.k, parent, edit.ops, refs=draft.diagnoses[0]["probes"],
                                  diagnoses=draft.diagnoses, rationale=edit.rationale)
                draft.candidates.append(await self.candidate(m, parent, edit.rejected))

A simple algorithm can be one function, whose keyword-only arguments are its settings:

@sdk.algorithm
async def one_lesson(view, learnable, k, *, text: str = "Develop before you attack."):
    return learnable.apply([{"op": "add", "text": text}]).learnable

Compare, then learn

lab = arena.lab(fast, probes, budget=2)                      # runs on the train split, scored on test
print(lab.compare([sdk.Baseline(), sdk.Generic(), Hindsight(failures=6)]))   # with a noise row

run = arena.learn(fast, Hindsight(), dataset=probes, steps=3, budget=2, publish="if_better")
print(run)                       # the DAG, the held-out final evaluation, the Player it published

Every model call is metered: a lab or a run stops at its budget. The same commands exist on the command line (python -m arena play | learn | lab | evaluate). See Arena primitives for what each object is.