The Stack

Running a Remote Bot Unattended

A remote bot only plays while its process is connected, and season fixtures are called at a set time. Three setups that get it to its matches on time: an always-on host, a schedule-aware runner, or letting Chipzen host it.

A remote bot runs on your machine and plays on Chipzen over a WebSocket. That is the point of the remote track: your code, your hardware, your model API, no container build. It also means the bot plays only while your process is connected. While your laptop sleeps, the bot can't play.

For challenges you start by hand, that's no problem. Season fixtures and tournament rounds are different: they are called at a set time, whether or not your process happens to be connected. So a remote bot can miss a fixture simply because its process wasn't connected when the match was called. The bot itself is fine. The strategy is fine. The fixture is still recorded as a walkover, and if that has happened to you, it's a frustrating way to lose a match. This post is about setting things up so it doesn't happen again.

It covers what the platform expects from a remote bot, and three setups that meet it without you at the keyboard. The code is Python, using chipzen-bot from PyPI. The same endpoints work from the JavaScript and Rust SDKs, or plain HTTP.

What showing up means

A season fixture has a published start time, in UTC. When it comes due, the platform looks for your bot in the lobby, which is the WebSocket that run_external_bot holds open. If the bot is there, the match is dispatched and the SDK plays it. If it isn't, the platform tries again a couple of times over the next few minutes and then records a walkover. If your opponent showed up, they get the win.

Tournaments are stricter. Being connected at the start time is your check-in, and a bot that isn't connected then is left out of the bracket. After that it has to stay connected until it's knocked out. Each round is called when the previous one finishes, and a bot that isn't there when its match is called forfeits it.

So the requirement is easy to state: the process is running and connected at the right times, with nobody watching. Three details make that harder than it sounds.

The SDK gives up after a short reconnect budget. run_external_bot reconnects after a drop, by default 5 times with backoff starting at half a second, about 15 seconds in total. Then it returns. That's the right default for a script you're watching. Unattended, a minute-long network drop ends the session and nothing starts it again.

One connection per bot. A new lobby connection for a bot replaces the old one. Run two copies of the same bot, say one on your laptop and one on a server, and they take turns kicking each other off.

Lobby time is part of the free plan. Per account and UTC day, the free plan includes 8 hours of lobby time (counted while a remote bot is connected), 100 matches, 20 matches an hour, and 20 matches that your bot starts itself through the rated queue or a challenge. Season fixtures and tournaments are exempt from all of these. When lobby time runs out, the queue and challenges stop starting new matches until midnight UTC. A connected bot still gets its fixtures.

That last point shapes the choice below. Staying connected around the clock works for fixtures. It also spends your lobby time on waiting.

Setup 1: an always-on host

The simplest setup. Run the bot somewhere that doesn't sleep: a small VPS, a spare Raspberry Pi, a home server. Wrap the SDK call in a loop, so a lost session gets retried instead of ending the process, and give the reconnect budget more room.

import asyncio
import logging
import os

from chipzen import Action, Bot, GameState, RetryPolicy, run_external_bot

logging.basicConfig(level=logging.INFO)


class MyBot(Bot):
    def decide(self, state: GameState) -> Action:
        # your strategy here
        if "check" in state.valid_actions:
            return Action.check()
        return Action.fold()


# More room than the default (5 attempts, about 15 seconds).
POLICY = RetryPolicy(max_reconnect_attempts=10, initial_backoff_ms=1_000, max_backoff_ms=30_000)


async def main() -> None:
    while True:
        try:
            await run_external_bot(
                MyBot,
                bot_id=os.environ["CHIPZEN_BOT_ID"],
                token=os.environ["CHIPZEN_EXTBOT_TOKEN"],
                retry_policy=POLICY,
            )
            logging.warning("lobby session ended, reconnecting in 30s")
        except Exception:
            logging.exception("lobby connect failed, retrying in 30s")
        await asyncio.sleep(30)


if __name__ == "__main__":
    asyncio.run(main())

The token and bot id come from environment variables, not the file, so the code can live in a repo and the secret can't. Passing the class instead of an instance gives each match a fresh MyBot, which saves you from state leaking between matches.

The loop covers a dropped connection. It doesn't cover the process dying or the machine rebooting. That's the supervisor's job. With Docker:

services:
  bot:
    build: .
    restart: unless-stopped
    env_file: .env    # CHIPZEN_BOT_ID=... and CHIPZEN_EXTBOT_TOKEN=cz_extbot_...

Or with systemd, if the bot runs straight on the host:

[Unit]
Description=Chipzen remote bot
After=network-online.target
Wants=network-online.target

[Service]
WorkingDirectory=/opt/mybot
EnvironmentFile=/opt/mybot/.env
ExecStart=/opt/mybot/.venv/bin/python run.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

A restart in the middle of a match isn't an automatic forfeit. When the bot reconnects, the platform hands the in-flight match back to it and the SDK picks it up. The turn clock keeps running while you're down, though, so any turn that falls due gets played for you. Restart fast.

What this costs: a bot that connects at midnight UTC has used its 8 free hours of lobby time by 08:00 UTC. From then on it still plays every fixture and tournament match, but the rated queue and challenges refuse it until the next day. If seasons and tournaments are what you care about, that's fine and this is the setup to use. If you also want rated games every day, read on.

Setup 2: a schedule-aware runner

A remote bot can read its own schedule. GET /api/external-api/fixtures/upcoming, authenticated with the same cz_extbot_ token, returns the bot's upcoming season fixtures, soonest first. Each one has scheduled_at in UTC and the fixture's decision_clock_seconds. So instead of sitting in the lobby all day, the bot can connect when there's something to play.

The sketch below polls the schedule once a minute. It connects ten minutes before each fixture and stays for an hour. In an optional daily window it also joins the rated queue, and stops queueing for the day once it hits a plan limit. The rest of the time it disconnects, but never in the middle of a match.

import asyncio
import json
import logging
import os
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone

from chipzen import Action, Bot, GameState, run_external_bot

API = "https://chipzen.ai/api/external-api"
BOT_ID = os.environ["CHIPZEN_BOT_ID"]
TOKEN = os.environ["CHIPZEN_EXTBOT_TOKEN"]
LEAD = timedelta(minutes=10)    # connect this long before a fixture
HOLD = timedelta(minutes=60)    # and stay at least this long after it starts
PLAY_HOURS_UTC = range(8, 12)   # optional: rated queue from 08:00 to 12:00 UTC


class MyBot(Bot):
    in_match = False

    def on_match_start(self, match_info: dict) -> None:
        self.in_match = True

    def on_match_end(self, results: dict) -> None:
        self.in_match = False

    def decide(self, state: GameState) -> Action:
        # your strategy here
        if "check" in state.valid_actions:
            return Action.check()
        return Action.fold()


def api(method: str, path: str) -> dict:
    req = urllib.request.Request(
        API + path,
        method=method,
        data=b"{}" if method == "POST" else None,
        headers={
            "Authorization": f"Bearer {TOKEN}",
            "Content-Type": "application/json",
            # urllib's default User-Agent is blocked at the edge; send your own.
            "User-Agent": "my-bot-scheduler/1.0",
        },
    )
    with urllib.request.urlopen(req, timeout=15) as resp:
        return json.load(resp)


def fixture_windows() -> list[tuple[datetime, datetime]]:
    fixtures = api("GET", "/fixtures/upcoming")["fixtures"]
    starts = [datetime.fromisoformat(f["scheduled_at"].replace("Z", "+00:00")) for f in fixtures]
    return [(s - LEAD, s + HOLD) for s in starts]


async def main() -> None:
    bot = MyBot()
    lobby: asyncio.Task | None = None
    windows: list[tuple[datetime, datetime]] = []
    queue_spent_on = None  # UTC date the plan's daily allowance ran out

    while True:
        now = datetime.now(timezone.utc)
        try:
            windows = await asyncio.to_thread(fixture_windows)
        except Exception:
            logging.exception("schedule fetch failed, keeping the last one")

        in_fixture_window = any(start <= now <= end for start, end in windows)
        in_play_hours = now.hour in PLAY_HOURS_UTC and queue_spent_on != now.date()
        connected = lobby is not None and not lobby.done()

        if (in_fixture_window or in_play_hours) and not connected:
            bot.in_match = False
            lobby = asyncio.create_task(run_external_bot(bot, bot_id=BOT_ID, token=TOKEN))
        elif not (in_fixture_window or in_play_hours) and connected and not bot.in_match:
            lobby.cancel()  # nothing scheduled: stop spending lobby time

        if in_play_hours and connected and not in_fixture_window and not bot.in_match:
            try:
                status = await asyncio.to_thread(api, "GET", "/matchmaking/status")
                if status["status"] != "queued":
                    await asyncio.to_thread(api, "POST", "/matchmaking/join")
            except urllib.error.HTTPError as err:
                code = json.loads(err.read() or b"{}").get("error_code")
                if code in ("CHALLENGE_QUOTA_EXCEEDED", "FREE_TIER_LIMIT_EXCEEDED"):
                    queue_spent_on = now.date()  # a plan limit: stop queueing for today
                else:
                    logging.warning("queue join refused: %s", code)

        await asyncio.sleep(60)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())

A few things the sketch relies on, and a few it leaves to you.

Matches arrive through the lobby. Joining the queue returns queued or matched, never a match id. The match itself is pushed down the lobby connection, and run_external_bot plays whatever arrives there. That's why the queue calls only happen while the lobby task is up. The rated queue pairs you with another remote bot that's waiting. If none is, you may get a house bot after a short wait instead.

It never queues inside a fixture window. A rated game started two minutes before a fixture is still running when the fixture that counts is called.

Challenges work the same way. To pick your opponent, GET /api/external-api/challenges/remote/opponents lists the remote bots connected right now, and POST /api/external-api/challenges/remote with {"opponent": "<bot name or id>"} challenges one. The other side has about a minute to accept, so a bot that wants to be challengeable polls GET /api/external-api/challenges/remote and accepts its inbound challenges with POST .../challenges/remote/{challenge_id}/accept. For an unrated warm-up, POST /api/external-api/challenges/house-bot starts a match against a house bot. Every match your bot starts this way counts toward the 20 a day.

Tournaments aren't in that endpoint. It returns season fixtures only. If your bot is entered in a tournament, add the start time from the tournament page as another window, and hold the connection until the bot is out.

The runner still runs all day. It goes under the same supervisor as setup

  1. What changes is how long the bot sits in the lobby. And don't run this next

to setup 1 for the same bot. Two processes, one bot, and they evict each other.

Setup 3: let Chipzen host it

If your bot doesn't need your machine, upload it. Hosted bots are started by the platform for each match, so they're present for every fixture and every tournament round by construction. The Bot class and decide are the same code; getting started covers packaging and upload.

The trade-offs, plainly:

  • No internet during a match. A bot that calls an LLM API or any other

outside service can't run this way. That's what the remote track is for.

  • A fixed budget. The bot runs in a sandboxed container with resource

limits and a per-decision time budget. A large model or a GPU won't fit.

  • It's a different bot. An uploaded bot is rated on the uploaded ladder.

The rating your remote bot has earned stays with the remote bot.

Which one

If the bot calls a model API or needs your hardware, it stays remote. Start with setup 1, and move to setup 2 when you want daily rated play on top of seasons and tournaments. If it's pure code that fits in a container, setup 3 takes uptime off your list entirely.

Whichever you pick, test it before the next season starts: once fixtures are drawn, fixtures/upcoming should return your schedule, and your logs should show the lobby connecting before the first one. The external-API onboarding guide covers tokens and the first connection.