Integration guide.

MJOT returns a mahjong move for a given game position. You send the mjai event stream of your seat's perspective; the server returns the mjai reaction (the move to make) plus a top-k candidate distribution. You never send or receive observation tensors — only the public mjai protocol event stream.

Overview

The server is stateless. Every /v3/react call is self-contained:

  1. You upload the events of the current hand (kyoku), ending at the event that requires a decision from your seat.
  2. The server replays that stream from scratch, runs the model once on the last event, and returns the reaction to it.
  3. You apply that reaction in your game, receive more events, and call again at your next decision point.

There is no session, no connection state, and no ordering to maintain across calls. Two rules only: the last event is the one you get a decision for, and the stream is from one seat's perspective — tiles you can't see are "?".

Authentication

Every endpoint except POST /v3/redeem and GET /healthz requires an API key:

Authorization: Bearer <key>

The Bearer prefix is optional — a raw key in the Authorization header also works. Keys are 32 alphanumeric characters, obtained by redeeming a code or purchasing a plan.

Base URL: https://mjapi.shinkuan.me. All requests over HTTPS.

POST /v3/react

The main endpoint. Returns the move for the final event in your stream.

Request

  • Body: JSON, plain (Content-Type: application/json) or gzip-compressed. Gzip is auto-detected by its magic bytes — just POST the gzipped bytes; no Content-Encoding header needed. Prefer gzip: an event stream compresses to a few KB.
  • Limits: ≤ 512 events; ≤ 1 MiB (compressed and decompressed).
request bodyPOST /v3/react
{
  "model": "4p-ot2",      // model id from GET /v3/models; omit -> server default
  "player_id": 2,          // your seat. 4p: 0-3, 3p: 0-2
  "events": [              // mjai events, your perspective, chronological
    {"type": "start_game", "names": ["A","B","C","D"]},
    {"type": "start_kyoku", "bakaze": "E", "kyoku": 1, "honba": 0,
     "kyotaku": 0, "oya": 0, "dora_marker": "2s",
     "scores": [25000,25000,25000,25000],
     "tehais": [["?","?","?","?","?","?","?","?","?","?","?","?","?"],
                ["?","?","?","?","?","?","?","?","?","?","?","?","?"],
                ["7p","6p","8m","9s","3m","2s","P","8m","1m","1s","7m","W","5mr"],
                ["?","?","?","?","?","?","?","?","?","?","?","?","?"]]},
    {"type": "tsumo", "actor": 2, "pai": "5p"}   // <- decision point for seat 2
  ]
}

Perspective / censoring rules

  • In start_kyoku, tehais[i] for every seat other than player_id must be 13 "?" placeholders. Your own hand is revealed.
  • A tsumo drawn by another seat has "pai": "?". Your own draws show the real tile.
  • Everything else (discards, calls, dora, reach, scores) is public — send it verbatim.

Minimal stream: you only need start_game plus the events of the current kyoku. You do not need to replay earlier kyoku — this keeps every request small and well under the 512-event cap.

Response — 200 OK

{
  "reaction": {"type": "dahai", "actor": 2, "pai": "W", "tsumogiri": false},
  "candidates": [
    {"action": "dahai:W",  "prob": 0.83},
    {"action": "reach",    "prob": 0.10},
    {"action": "dahai:5p", "prob": 0.04}
  ],
  "model": "4p-ot2"
}
FieldMeaning
reactionThe move to make, as a standard mjai event with actor = player_id. Always the model's best (argmax) move — the exact tile, the exact meld. null if your seat has no legal action for the last event.
candidatesUp to k actions ranked by probability (softmax over legal actions). candidates[0] always corresponds to reaction. k depends on your plan. Labels are coarse action types — the exact move is in reaction.
modelThe model id that served the request.

When reaction is null: the last event was not a decision point for your seat (e.g. an opponent's discard you cannot call on). Common decision points — your own tsumo (what to discard), an opponent's dahai (pon/chi/kan/ron), a kakan/kita (chankan ron).

Multi-step moves

The reaction is a single mjai event. Declaring a move and choosing its follow-up are separate decisions — when the reaction is one of these, append it to your stream and call again:

First reactionAppend it, call again, and get
reachthe discard of the declared tile (dahai)
chi / ponthe discard after melding (dahai)
daiminkan / ankan / kakanhandled via the rinshan draw your game sends next
worked example — reach
POST /v3/react   events: [ ..., {"tsumo", actor:0, pai:"4m"} ]
  -> reaction {"type":"reach","actor":0}

# append the reach event, ask again:
POST /v3/react   events: [ ..., {"tsumo",...}, {"type":"reach","actor":0} ]
  -> reaction {"type":"dahai","actor":0,"pai":"2m","tsumogiri":false}

In a normal game loop this falls out naturally: you send each reaction to the game, it echoes the accepted event back into your stream, and you re-query at the next decision point.

Game review

Submit the complete mjai log of one game and get, for every decision your seat faced, the model's chosen move, its top-k candidates, and how the move you actually made compares. Reviews run in the background at lower priority than live /v3/react traffic: submitting returns a job id which you poll. Availability is per plan (GET /v3/keyreviews_per_day; 0 → 403).

POST /v3/review

{
  "model": "4p-ot2",   // optional, same default rules as /v3/react
  "player_id": 2,      // see input shapes below
  "events": [ /* the full game, start_game onward */ ]
}

JSON or gzip like /v3/react; limits: ≤ 4096 events, ≤ 1 MiB, exactly one start_game. Two input shapes are accepted, detected from the start_kyoku hands:

  • Perspective log — your seat's censored view, exactly like /v3/react (other seats' tehais and draws are "?"). The reviewed seat is inferred automatically; player_id is optional and must match if present.
  • Full-view log — everything visible (e.g. a converted game record). player_id is required to pick the seat to review; the server censors the log down to that seat's view internally, so the model sees exactly what the player saw. The same log can be submitted once per seat.

Censoring must be consistent — two visible hands, a partially visible hand, or draw visibility contradicting the classification → 400. The whole stream is replayed through the rules engine at submit time, so an illegal or out-of-order log → 422. Quota is only charged on a 202.

// 202 Accepted
{ "review_id": "3f2a…(32 hex)", "status": "queued" }

Review-specific 429s (each with Retry-After): daily reviews_per_day quota spent (3600), a review already queued or running on this key (30), global review queue full (60). Submits consume the normal per-key rpm bucket but not rpd — reviews have their own daily meter.

GET /v3/review/{review_id}

Poll the job — a 2–5 s cadence is plenty. Polls consume no per-key rpm/rpd (only the per-IP limit applies). Jobs are visible only to the key that created them; any other id → 404. Results are kept ~7 days, then purged. Jobs interrupted by a server restart resume automatically.

{ "status": "queued" }
{ "status": "running", "progress": 0.42 }
{ "status": "failed", "error": "could not process event stream" }
{ "status": "done", "model": "4p-ot2", "player_id": 2, "view": "full",
  "summary": { "n_decisions": 161, "n_match": 128,
               "match_rate": 0.795, "avg_actual_prob": 0.62 },
  "decisions": [
    { "i": 42,                    // index into YOUR submitted events array:
                                  // the event this decision responds to
      "ai": {"type": "dahai", "actor": 2, "pai": "W", "tsumogiri": false},
      "ai_label": "dahai:W",      // coarse label (see Action labels)
      "actual": {"type": "dahai", "actor": 2, "pai": "5p", "tsumogiri": false},
      "actual_label": "dahai:5p", // what you actually did ("none" = passed)
      "actual_prob": 0.21,        // model's probability of YOUR move
      "actual_rank": 2,           // YOUR move's rank among legal actions
      "match": false,             // ai_label == actual_label
      "candidates": [ {"action": "dahai:W", "prob": 0.63} /* … plan topk */ ]
    }
  ] }
FieldMeaning
summaryn_decisions / n_match / match_rate (share of decisions where you and the model chose the same action label) and avg_actual_prob (mean model probability of the moves you actually made — a soft accuracy measure).
decisions[].iIndex into the events array you submitted — the event this decision responds to. Use it to seek a replay viewer to the exact moment.
decisions[].ai / ai_labelThe model's move as a full mjai event, plus its coarse label.
decisions[].actual / actual_labelYour move. actual_label "none" means you passed on a call opportunity (actual is null there).
decisions[].actual_prob / actual_rankHow the model rated your move: its softmax probability and its rank among the legal actions (1 = the model's top choice).
decisions[].candidatesTop-k candidates at that decision, capped by your plan's topk.

Decision points cover every genuine choice: your discards, riichi (declaration and the following discard), calls — including opponent discards you let pass. Forced moves with a single legal action are omitted.

Other endpoints

EndpointPurpose
GET /v3/modelsLists the models your key's plan may use, e.g. 4p-ot2 (4-player), 3p-ot (sanma). Use an id as the model field in /v3/react.
GET /v3/keyYour key's status: plan, expiry, usage today, rpd, rpm, topk, and the review meter (reviews_today / reviews_per_day).
POST /v3/redeemRedeem a prepaid code (no auth). Default mints a new key; pass renew_key to stack time onto a key you already hold instead. Renewing extends the key's remaining time — it never resets or rotates it.
GET /healthzLiveness + per-model queue depth (no auth).

Errors

All errors return {"error": "<message>"}:

StatusMeaning / what to do
400Malformed request (bad JSON, unknown model, bad events). Fix the payload; do not retry unchanged.
401Missing / unknown / expired / disabled key. Check the key; renew if expired.
403Your plan may not use this model. Pick one from GET /v3/models.
422The event stream could not be replayed (out of order, inconsistent, or illegal). Verify ordering and censoring.
429Rate limit / daily quota / concurrency cap. Honor the Retry-After header (seconds), then retry.
503Temporarily overloaded or starting up. Back off briefly and retry.

Implement exponential backoff on 429/503/500.

Rate limits & plans

Limits are per key and enforced exactly: requests/minute with a burst allowance (rpm), a hard daily counter (rpd), an in-flight cap (max_concurrent), and the candidate count (topk). Read your live values from GET /v3/key. Design your client to:

  • Call only when your seat actually has a legal action. You know your own hand — decide locally whether a call is even possible. Querying on every opponent discard roughly triples your request count and gains nothing (the server returns reaction: null). A hanchan is ~160 real decisions per player.
  • Serialize requests per game — single-table play never needs more than one in flight.
  • Respect Retry-After; never batch client-side — the server micro-batches internally.

Action labels

candidates[].action uses these coarse labels. They describe the move type — the model's probability mass over kinds of moves — while the exact tiles and meld composition are always in reaction. A label is only ever emitted when that move is legal at the current decision point.

LabelDefinition
dahai:<pai>Discard exactly the tile <pai> — one label per tile, 37 possibilities: 1m–9m, 1p–9p, 1s–9s, E S W N P F C, plus the red fives 5mr / 5pr / 5sr (a red five is its own label, distinct from the plain five). Whether it is a tsumogiri (cutting the drawn tile) is in the reaction's tsumogiri flag, not the label.
reachDeclare riichi. This is only the declaration — which tile to cut is a separate follow-up decision (see Multi-step moves).
chi_lowChi where the called tile is the lowest tile of the resulting run — e.g. call an opponent's 3m while holding 4m + 5m (3-4-5). 4p only.
chi_midChi where the called tile is the middle tile of the run — e.g. call 4m holding 3m + 5m. 4p only.
chi_highChi where the called tile is the highest tile of the run — e.g. call 5m holding 3m + 4m. 4p only.
ponCall pon on the last discard (which tile is implied by the discard itself).
kanAny kan — ankan (closed, from your own turn), daiminkan (open, calling a discard), or kakan (adding to your pon). The three are one label; the reaction's event type tells you which one, with the exact tiles in consumed.
horaWin. Tsumo or ron is not distinguished by the label — in the reaction, target == actor means tsumo, otherwise ron off target.
ryukyokuDeclare kyuushu kyuuhai (nine terminals/honors abortive draw). Only ever legal on your first draw of an uninterrupted hand.
nukidoraSet aside a North tile as nukidora. 3p only; the reaction event type is kita.
noneDo nothing — decline a call opportunity (let the discard pass) or, on a kakan, decline the chankan ron. When none wins the argmax the reaction is {"type":"none"}.

Reaction event shapes (standard mjai; actor is always your seat):

{"type":"dahai","actor":2,"pai":"W","tsumogiri":false}
{"type":"reach","actor":2}
{"type":"chi","actor":2,"target":1,"pai":"3m","consumed":["4m","5mr"]}
{"type":"pon","actor":2,"target":0,"pai":"4m","consumed":["4m","4m"]}
{"type":"daiminkan","actor":2,"target":1,"pai":"5m","consumed":["5m","5m","5mr"]}
{"type":"ankan","actor":2,"consumed":["F","F","F","F"]}
{"type":"kakan","actor":2,"pai":"6m","consumed":["6m","6m","6m"]}
{"type":"hora","actor":2,"target":3}         // target==actor for tsumo
{"type":"ryukyoku"}
{"type":"kita","actor":2,"pai":"N"}          // 3p nukidora
{"type":"none"}

Three-player (sanma)

  • player_id range is 0–2. Seat arrays may be native length-3 or 4p-padded length-4 — both are accepted.
  • Nukidora is the event type kita (nukidora accepted as an input alias). There is no chi in sanma.
  • Always pass model explicitly (e.g. 3p-ot) — omitting it selects the default 4-player model, which rejects a sanma stream.

Examples

curl

shell
curl -sS https://mjapi.shinkuan.me/v3/react \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "4p-ot2",
    "player_id": 0,
    "events": [
      {"type":"start_game","names":["A","B","C","D"]},
      {"type":"start_kyoku","bakaze":"E","kyoku":1,"honba":0,"kyotaku":0,"oya":0,
       "dora_marker":"2s","scores":[25000,25000,25000,25000],
       "tehais":[["1m","2m","3m","4p","5p","6p","7s","8s","9s","E","E","W","W"],
                 ["?","?","?","?","?","?","?","?","?","?","?","?","?"],
                 ["?","?","?","?","?","?","?","?","?","?","?","?","?"],
                 ["?","?","?","?","?","?","?","?","?","?","?","?","?"]]},
      {"type":"tsumo","actor":0,"pai":"3s"}
    ]
  }'

Python

python — requests, gzip
import gzip, json, requests

def react(key, model, player_id, events):
    body = gzip.compress(json.dumps(
        {"model": model, "player_id": player_id, "events": events}).encode())
    r = requests.post("https://mjapi.shinkuan.me/v3/react", data=body,
                      headers={"Authorization": f"Bearer {key}"})
    r.raise_for_status()
    return r.json()

resp = react(KEY, "4p-ot2", 0, events)
reaction = resp["reaction"]          # apply this move in your game
if reaction and reaction["type"] == "reach":
    events.append(reaction)          # two-step: ask again for the discard
    reaction = react(KEY, "4p-ot2", 0, events)["reaction"]

JavaScript

javascript — fetch
async function react(key, model, playerId, events) {
  const res = await fetch('https://mjapi.shinkuan.me/v3/react', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${key}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model, player_id: playerId, events }),
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

const { reaction, candidates } = await react(KEY, '4p-ot2', 0, events)

The API is versioned under /v3 — additive changes (new fields, new models) won't break you; read fields defensively. Your key is stable across renewals.