HTTP API reference¶
gorged is gorge's game server: it runs bot tables, seats humans and bots, and serves
everything over plain HTTP plus one Server-Sent-Events stream. This page is the reference for
that surface, endpoint by endpoint: who may call what, which flag arms it, the bodies, the
statuses and the errors. It is for anyone writing a client: a bot that plays a seat over HTTP,
a dashboard, a replay viewer. For a walkthrough that plays a game end to end, start with
Playing a seat over HTTP; come back here for the field-by-field detail.
The machine-readable spec is the authority, and this page is a reading of it:
| File | What |
|---|---|
openapi.json |
OpenAPI 3.1, every operation, schema and real example. Feed it to a client generator. |
openapi.yaml |
The same document as YAML (rendered from the JSON). |
schemas/gorge-protocol.schema.json |
The wire types alone, as one JSON Schema 2020-12 document ($defs). |
| Interactive reference | The spec rendered by Redoc 2.1.5 (needs JavaScript). |
How the spec stays true
The spec is derived from the code and guarded by a test,
server/host/httpapi/openapi_test.go.
It reads every method-qualified route that server/host/httpapi and server/gorged
register, and fails when a registered route is missing from the spec or the spec has an
operation that no code registers. It also builds a fully armed handler and checks that each
of httpapi's operations is actually routed. Every schema marked x-go-type is generated
from that Go type's source, so a renamed field or a new one fails the test until the spec
is regenerated. The decision-kind enum is compared with decision.Kinds. The examples are
real responses, captured from a live server on 2026-10-20 at commit ad3777023.
After you change a route or a wire type, regenerate the derived parts and then document
any new route by hand in openapi.json:
Try it¶
Start a server (see Install and build), then:
GORGE=http://127.0.0.1:8080
curl -s $GORGE/api/tables | python3 -m json.tool | head -20
curl -sN --max-time 2 $GORGE/api/stream | head -c 300
Against a two-table server started with -tables 2 -seats 2 -humans 0, the first command
printed this (2026-10-20, the server on another port):
[
{
"id": "t1",
"name": "Table 1",
"seats": 2,
"spectator": "public",
"state": "live",
"match": 1,
"perpetual": false,
"format": "constructed",
"bot_policy": "lethal-pressure",
"seat_names": [
"Player 1",
"Player 2"
],
"mulligans": 1,
"auto_mana": true
},
and the second printed the stream's first frame, hello:
event: hello
data: {"v":1,"t":"hello","seq":0,"body":{"session":"sd10b14058b154be1d85ac19eada7c909","tables":[...
Who may call what¶
gorged has no login. A request is either a spectator or holds a seat claim.
- Spectator. Every read endpoint works without credentials and returns what the table's
spectator visibility allows.
publichides every hand;omniscientshows them. You set it with-spectatorfor the startup tables and-vsbot-spectatorfor play-vs-bot tables. - Seat claim. A seat token resolves to exactly one (table, seat) pair. Send it as
Authorization: Bearer <token>or as?token=<token>; the header wins when both are present. The server never trusts a seat number the client sends. It only compares that number with the claim:
| The request | Result |
|---|---|
Names no seat (no ?seat=), on a read endpoint |
Spectator view; no token is consulted |
?seat=N or a seat action, on a server with no seat claims at all (no -humans, -vsbot or -events) |
403 forbidden, "this server is spectator-only" |
| Has no valid token | 401 unauthorized |
| The token's table is not this table | 403 forbidden |
The token's seat is not N |
403 forbidden |
| The claim matches | The seat's own view and actions |
You get a seat token in one of three ways:
POST /api/gamesreturns one for the seat it creates. That response is the only place the token ever leaves the server.- With
-humans 0,2, gorged mints one per human slot of tablet1and prints the join URL for each at startup:table t1 seat 0 joins at http://127.0.0.1:8080/t/t1?seat=0&token=…. -seat-token TOKENfixes the first human slot's token. It is meant for tests and local use.
Which routes exist¶
Some routes exist only when a flag mounts them. Without the flag, an /api/ path answers the
JSON 404 no such endpoint, and a non-API path falls through to the web client.
| Flag | Mounts |
|---|---|
| none | The lobby, matches, seat and stream routes, and the /art/ and /cards/ card cache |
-vsbot |
POST /api/games. It also arms seat claims, so a seat request without a token is a 401, not a 403. |
-humans 0,… |
Seat claims for those slots of t1 |
-feedback DIR |
POST /api/feedback. The binary's default directory is feedback; an empty value disarms the route. |
-pprof |
/debug/pprof/…. gorged refuses the flag unless -addr is a loopback address. |
-events |
/api/events…. It needs a binary that links a draft-event runtime through server/gorged.Extensions; gorge's own cmd/gorged refuses it at startup. |
gorged review |
A separate, read-only mode: the lobby, match and stream routes over saved games, plus GET …/decisions. Every seat route answers 403. |
-manabrew |
The ManaBrew adapter under /api/manabrew/v0/tables/. This is a separate, deprioritised protocol and is not covered here (see below). |
Every other path (/, /t/t1, …) serves the embedded web client with an SPA fallback to
index.html. A binary built without the web bundle answers those paths with
503 web build missing.
Endpoints¶
The Auth column tells you whether an endpoint is open to spectators, or what credential it
reads. A seat token exists only when one of the seat flags is on; see
Who may call what. Every /api/ route also answers 405
method_not_allowed to a wrong method. Full request and response schemas, and an example for
each, are in openapi.json and the interactive reference.
Lobby¶
| Method | Path | What | Auth | Statuses |
|---|---|---|---|---|
GET |
/api/tables |
The lobby's tables (TableInfo[]). Play-vs-bot tables are not listed. |
none | 200 |
GET |
/api/decks |
The deck catalogue (DeckInfo[]). With ?policy=NAME, only the decks a game against that policy may use. |
none | 200 |
GET |
/api/bot-policies |
The offered bot policies and the default (BotPolicyList) |
none | 200 |
GET |
/api/stats/decks |
Deck leaderboard over finished bot-vs-bot games (DeckTally). ?min_games=N, default 10. |
none | 200, 400 |
Matches¶
| Method | Path | What | Auth | Statuses |
|---|---|---|---|---|
GET |
/api/tables/{t}/matches |
The table's matches, finished ones first and the live one last (MatchInfo[]) |
none | 200, 404 |
GET |
/api/tables/{t}/matches/{k}/view |
The board at ?seq= (inclusive; default: the latest event). With ?seat=, that seat's view, including its pending decision when seq is the live head (View). |
spectator, or a seat token with ?seat= |
200, 400, 401, 403, 404, 409 |
GET |
/api/tables/{t}/matches/{k}/events |
Redacted events from ?since= (inclusive, default 0) to the head, each with its transcript line (EventBody[]) |
spectator, or a seat token with ?seat= |
200, 400, 401, 403, 404, 409 |
GET |
/api/tables/{t}/matches/{k}/decisions |
Only on a gorged review server: the recorded match's decision index, one entry per intent, with the options offered, the answer and the bot's explain note (Replays) |
none | 200, 400, 404 |
k is the match index and counts from 1; 0 or a non-number is a 400. A seq or since past
the head is a 409 beyond_head, and the body's head names the last valid seq so you can
clamp and retry:
Playing a seat¶
| Method | Path | What | Auth | Statuses |
|---|---|---|---|---|
GET |
/api/tables/{t}/matches/{k}/pending?seat=N |
The full decision your seat is being asked (Decision) |
seat token, required | 200, 400, 401, 403, 404, 409 |
POST |
/api/tables/{t}/matches/{k}/intent |
Answer it (Intent body). ?auto=1 marks an autopilot answer. |
seat token, required | 204, 400, 401, 403, 404, 409 |
POST |
/api/tables/{t}/matches/{k}/undo |
Rewind the live match to the decision you last answered | seat token, required | 204, 401, 403, 404, 409 |
The stream¶
| Method | Path | What | Auth | Statuses |
|---|---|---|---|---|
GET |
/api/stream |
Server-Sent Events. A fresh connection starts with hello. |
none | 200 |
POST |
/api/subscribe |
{session, table, mode}: overview or focus; table * means every table, overview only |
none (the session id is the capability) | 204, 400, 404 |
POST |
/api/unsubscribe |
{session, table} |
none | 204, 400, 404 |
The stream has its own page: The event stream.
Games, feedback, cards and profiling¶
| Method | Path | What | Auth | Statuses |
|---|---|---|---|---|
POST |
/api/games |
Start a play-vs-bot game (CreateGameRequest in, CreateGameResponse out, including the seat token and join path) |
none | 200, 400, 404 |
POST |
/api/feedback |
A bug report, as multipart/form-data with fields text (required), url, table, seat, screenshot (PNG) and client.json. Answers 201 {"id": …}. |
none | 201, 400, 500 |
GET |
/art/named?exact=NAME |
Card art reference, Scryfall-shaped: {name, image_uris: {normal}} |
none | 200, 400, 404, 502, 503 |
GET |
/art/token?exact=NAME&types=…&ppt=P/T |
Token art reference, same shape | none | 200, 400, 404, 502, 503 |
GET |
/art/blob/{key} |
The cached JPEG, Cache-Control: immutable |
none | 200, 404 |
GET |
/cards/named?exact=NAME |
A card's printed facts: name, mana_cost, type_line, oracle_text, power, toughness |
none | 200, 400, 404, 502, 503 |
GET |
/debug/pprof/, …/cmdline, …/profile, …/symbol, …/trace; POST …/symbol |
Go's net/http/pprof |
none (loopback only) | 200 |
The card routes fetch from Scryfall the first time a name is asked for, paced and
single-flight, and cache the result under -art-dir. A fetch that outlives the wait budget
answers 503 and keeps running, so a retry soon after is served from disk. Errors from these
gorged-owned routes (/art/, /cards/, /api/feedback, /debug/) are text/plain, not
ErrorBody.
Draft events¶
These 15 operations appear only on a binary that links a draft-event runtime and runs with
-events. A seat is identified by an event token (Authorization: Bearer or ?token=), and a
request without one is a spectator. The backend picks the status and code of its own refusals.
| Method | Path | Body in → out |
|---|---|---|
POST |
/api/events |
CreateEventRequest → CreateEventResponse |
GET |
/api/events |
→ EventState[] |
GET |
/api/events/sets |
→ EventSetInfo[] |
GET |
/api/events/personas |
→ EventPersonas |
GET |
/api/events/{id} |
→ EventState |
POST |
/api/events/{id}/join |
JoinEventRequest → EventState |
GET |
/api/events/{id}/pack |
→ DraftPack |
POST |
/api/events/{id}/pick |
PickRequest → DraftPack |
GET |
/api/events/{id}/pool |
→ EventPool |
POST |
/api/events/{id}/deck/check |
DeckRequest → DeckCheck |
POST |
/api/events/{id}/deck |
DeckRequest → DeckCheck |
POST |
/api/events/{id}/ready |
ReadyRequest → EventState |
POST |
/api/events/{id}/sideboard |
SideboardRequest → EventState |
GET |
/api/events/{id}/log |
→ EventLog |
POST |
/api/events/{id}/subscribe |
EventSubscribe → 204; routes the event's frames to a stream session |
The decision loop¶
A seat plays by repeating one exchange: read the pending decision, then answer it with option indices. gorge lists every legal option and validates every answer. A bot can choose badly, but it can never choose illegally.
sequenceDiagram
participant C as client (seat 0)
participant S as gorged
C->>S: GET .../pending?seat=0 (Bearer token)
S-->>C: 200 Decision {seq, player, kind, min, max, options[]}
C->>S: POST .../intent {seq, player, choices:[i,...]}
alt accepted
S-->>C: 204 (the game moves on)
else stale, out of range, wrong player, min/max
S-->>C: 409 conflict, message = the engine's reason
end
C->>S: GET .../pending?seat=0
S-->>C: 409 "no decision pending" while the bot thinks, so poll or watch the stream
A real attackers decision, as pending returned it:
{"seq": 550, "player": 0, "kind": "attackers", "prompt": "turn 4 — declare attackers",
"min": 0, "max": 1,
"options": [{"index": 0, "kind": "attacker", "label": "Attack with Experiment One at Bot",
"obj": 33, "player": 1}]}
The answer echoes seq and player and names indices:
Some rules about intents:
choicesmust be distinct, in range, and betweenminandmaxin number. The kind's own constraints apply too. For example, at most one option pergroupmay be chosen, and an option markedrequiredmust be included when it can be.- A stale
seqis refused:intent seq 153, pending decision seq 53. - Wait for a new
seqbefore answering again. Resubmitting the same decision before the server has moved on is never useful. - A
castoption on an auto-mana table may come withpayment_actions. You answer those with the intent'spaymentfield (a full plan) or itsannouncefield (pay in the payment window) instead ofchoices. A plainchoices: [i]on the cast option still works. - The request body is strict. A field the schema does not name, trailing data, or a body over 64 KiB is a 400.
Decision kinds¶
The set of kinds is closed (decision.Kinds), and the spec's Kind enum is checked against it.
| kind | min / max | options and answer |
|---|---|---|
priority |
1 / 1 | One option per legal action (play_land, cast, activate, ability, …), plus pass and concede. |
target |
per spell | One option per legal target (player or an object). target_effect says what the targeting does: damage, removal or granted statics. |
attackers |
0 / N | One attacker option per (creature, defender) pair. player is the defender seat; battle is a permanent being attacked. Options sharing a group are mutually exclusive. |
blockers |
0 / N | One block option per (blocker, attacker) pair, with attacker naming the attacker. group keeps each blocker on one attacker; min_blockers/max_blockers bound an attacker's blocker count. |
mulligan |
1 / 1, or k / k | Keep phase: keep, plus mulligan while one remains. Bottom phase: one bottom option per card in hand; choose as many cards as you took mulligans. |
modes |
MinCharmNum / CharmNum | One mode option per mode, in printed order. An unless-pay ask uses option mode unless_pay / unless_decline. |
trigger_order |
N / N | A permutation of your simultaneous triggers. choices[0] goes on the stack first, so it resolves last. |
trigger_optional |
1 / 1 | Exactly yes, then no. |
commander_zone |
1 / 1 | Exactly command_zone, then leave. This is asked of the commander's owner. |
choose |
varies | One list pick. Every option shares a kind saying what is chosen: x (with amount), exile, sacrifice, discard, search, dig, name, type, number, yes/no, keep, dungeon, room. |
replacement |
1 / 1 | Which applicable replacement applies next (obj is its source). Also mana colour options, and apply/decline for an optional replacement. |
arrange |
varies | An ordered subset. The chosen indices, in answer order, become pile A, and index 0 ends closest to the top. The rest form pile B, whose destination is the shared option kind (bottom, graveyard, exile, hand). rest may order pile B when restable is set. |
starting_player |
1 / 1 | The toss winner picks who takes the first turn. A host-run table asks this before the mulligans. |
The live captures in the spec include real priority, target, attackers, blockers,
mulligan, choose and starting_player decisions. The other six kinds are documented from
engine/decision/decision.go.
Errors¶
Every /api/ error has the same body, protocol.ErrorBody:
| Status | code |
When |
|---|---|---|
| 400 | bad_request |
A malformed parameter or JSON body, or a value the handler rejects (unknown format, mode or policy) |
| 401 | unauthorized |
A seat-scoped request with no seat claim |
| 403 | forbidden |
The server is spectator-only, or the claim is for another seat or table |
| 404 | not_found |
Unknown table, match, stream session or endpoint |
| 405 | method_not_allowed |
A known path with the wrong method |
| 409 | conflict |
The match is not in the state the request assumed: not live, not a human seat, nothing pending, or a stale or illegal intent. message is the engine's reason. |
| 409 | beyond_head |
seq or since is past the head; head holds the last valid seq |
| 500 | internal |
A bug or an I/O failure |
Conventions¶
- Unknown fields. Responses gain fields without notice: that is the additive rule of the wire protocol, so ignore fields you do not know. Requests are the opposite: unknown fields are refused.
- Omitted vs null. A field the schema lists as
requiredis always present. A Goomitemptyfield is omitted when it is zero, so a missingobj,amountorrequiredmeans 0 or false. A required array or object can benullwhen the server's value is nil. The schemas say exactly where ("type": ["array", "null"]). - Integers. Seeds are unsigned 64-bit and exceed 2^53, so parse them as 64-bit integers or
as strings, not as JS doubles.
vieweris 255 for a spectator view. - Seats are 0-indexed (
player,seat). Match indices are 1-indexed.
Not covered¶
- ManaBrew (
-manabrew,server/host/manabrewhttp): a separate, deprioritised adapter protocol, mounted under/api/manabrew/v0/tables/{t}/matches/{k}/{stream,send,state}. The drift guard does not cover it, because its routes are mounted under a computed prefix. - The SpellBench seat (
-sb-agent) is not HTTP. It speaks SpellBench v2 to an agent subprocess.