Bots
A bot is a registry row whose type selects how its moves are produced:
engine— the brain ships in the game module, asGameRules.botActions[username]. When a seated engine bot's turn starts, the DO resolves its row → username → move function, runs it in-process post-commit, and self-applies the returned move as that seat's action (a normal serialized command with a deterministiccommandId, so it dedupes and chains through consecutive bot turns). A bot game needs no external service.external— the bot is hosted elsewhere. On its turn the DO sends a single signed wake carrying the bot's freshly-committed observation; the bot later POSTs its move to/api/bot/action. Fire-and-forget, single attempt — a lost wake rides the turn deadline.local— client-driven, reserved for the future offline-solo transcript import. A registry row for identity only; never dispatched server-side.
A bot only ever sees its own seat's projection — the same fog-of-war a human at that seat gets — so a bot can never read hidden state.
Seating gates (shared by add-bot and create-solo, checked at the Worker
before minting): the game must be timed (bots ⇒ timed, so a broken brain is
backstopped by the deadline), the bot must support the schema version, a rated
game needs a rated-eligible bot, an engine bot needs a botActions entry for its
username, an external bot needs a webhook, and the game's botSeatable hook must
accept the pairing.
To write a bot brain for your own game, see Bots in a game module.
External-bot HMAC
Both directions (engine→bot wake, bot→engine action) are authenticated by an HMAC over the exact message body, using a per-bot key derived from one engine secret:
derivedKey = HMAC-SHA256(BOT_SIGNING_SECRET, botId)
signature = "v1," + base64(HMAC-SHA256(derivedKey, "<domain>:<message>"))
The domain tag (wake vs action) is inside the signed bytes, so a
signature captured in one direction can never verify in the other — no
reflection. The signature travels in the Eigen-Signature header both ways.
Registering a bot needs no new secret and no redeploy. Onboarding an external
bot is therefore: insert the row, derive that bot's key, and hand it to whoever
runs the bot — which may well be you. The bot's owner gets only the derived key
and never sees BOT_SIGNING_SECRET.
@eigeninteractive/server exports the derivation as an operator utility:
import { deriveBotKey } from "@eigeninteractive/server";
const key = await deriveBotKey(BOT_SIGNING_SECRET, botId); // base64
or, with no code at all:
echo -n "<botId>" | openssl dgst -sha256 -hmac "<BOT_SIGNING_SECRET>" -binary | base64
That key is a credential — it authenticates that bot to the engine for as long as it is registered. Because every key is derived from the one master secret, rotating a single bot's key means rotating the master, which rotates every bot's key. Issue a key only to an owner you would be willing to re-issue all of them for.
Verification is constant-time (crypto.subtle.verify).