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:
- You upload the events of the current hand (kyoku), ending at the event that requires a decision from your seat.
- The server replays that stream from scratch, runs the model once on the last event, and returns the reaction to it.
- 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; noContent-Encodingheader needed. Prefer gzip: an event stream compresses to a few KB. - Limits: ≤ 512 events; ≤ 1 MiB (compressed and decompressed).
{
"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 thanplayer_idmust be 13"?"placeholders. Your own hand is revealed. - A
tsumodrawn 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"
}| Field | Meaning |
|---|---|
| reaction | The 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. |
| candidates | Up 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. |
| model | The 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 reaction | Append it, call again, and get |
|---|---|
| reach | the discard of the declared tile (dahai) |
| chi / pon | the discard after melding (dahai) |
| daiminkan / ankan / kakan | handled via the rinshan draw your game sends next |
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/key → reviews_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_idis optional and must match if present. - Full-view log — everything visible (e.g. a converted game record).
player_idis 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), a per-key cooldown between submits — one review per 10 minutes (Retry-After carries the exact remaining seconds) — global review queue full (60), and too many failed reviews on this key today (3600). Submits consume the normal per-key rpm bucket but not rpd — reviews have their own daily meter, which resets at UTC midnight.
Only an accepted (202) submit arms the full 10-minute cooldown. A rejected submit costs ~30 s, so anything you can fix — an unusable log, review_no_seat, a model that doesn't match the log's player count, an unconvertible URL — can be corrected and resent in seconds. Failures on our side cost nothing: a 503/502/500 submit can be retried immediately, and a queued review that later ends failed with a server-side error stops counting against reviews_per_day — reviews_today drops back. A key that accumulates too many such failures in one day is refused until the next UTC day (429, Retry-After 3600).
POST /v3/review/url
{
"url": "https://tenhou.net/0/?log=2019050417gm-0029-0000-4f2a8622&tw=2",
"player_id": 2, // optional; see seat resolution below
"model": "4p-ot2" // optional; must match the log's game (3p/4p) if given
}The same background review, but you hand over a share link and the server does the fetch + conversion: it downloads the game record, converts it to a full-view mjai log, and runs the identical pipeline — same plan gate, quota, cooldown, and job lifecycle; poll the returned id exactly as below. Supported: tenhou.net log URLs and Mahjong Soul ?paipu=… links (Mahjong Soul requires a fetch account configured server-side, else 501).
The reviewed seat is picked in this order:
- You send
player_id→ it wins (whether or not the URL carries a seat). - No
player_id, but the URL carries a viewpoint (tenhou&tw=, Mahjong Soul_a…) → the URL's seat. - Neither →
400 review_no_seat: ask the user which seat to review and resend withplayer_id.
When model is omitted, the log's player count picks it (a 3-player log → the 3p default). Extra failure modes beyond /v3/review's: 400 paipu_bad_url (unsupported link), 400 game_mismatch (a model whose game differs from the log), 422 (record empty, expired, or unconvertible), 501/502 (Mahjong Soul not configured / login failed). All the same 429 quota/cooldown/queue gates apply.
GET /v3/review/{review_id}
Poll the job — a 4–5 s cadence. Polls consume no per-key rpm/rpd (playing and polling never compete), but they draw on a separate per-key read bucket of 20/min with a burst of 40, shared with GET /v3/shares and DELETE /v3/shared/{share_id} — a short job polled faster is fine (that's the burst); a sustained cadence above 20/min eventually answers 429 with Retry-After. Jobs are visible only to the key that created them; any other id → 404. Your most recent ~100 jobs are kept; older ones are evicted as new ones are submitted. Jobs interrupted by a server restart resume automatically.
The poll is meta-only — it never carries the result. A finished job answers with its share URL, and that URL (GET /v3/shared/{share_id}, below) is the one and only way the result body is read — by you or by anyone you give the link to:
{ "status": "queued", "progress": 0.0 }
{ "status": "running", "progress": 0.42 }
{ "status": "failed", "error": "could not process event stream" }
{ "status": "done",
"share_id": "aZ3kQ9mB2xLp", // null after a revoke: POST …/share to
// (re)issue a link, then fetch it
"url": "https://mjot.shinkuan.me/preview/s/aZ3kQ9mB2xLp" }POST /v3/review/{review_id}/share
Every review gets a share URL the moment it is submitted; this endpoint returns it. A share is not a copy: the public link serves the review job's own stored result, so it lives exactly as long as the review does — until newer submits push it out of your most-recent-jobs window, or you revoke it. There is no separate share cap and no serve cap on the public URL. Because a share can only point at a review this server computed, a share link is proof the numbers came from the model, not from an edited file.
The job must be done (queued/running/failed → 409 review_not_done). Creating consumes per-key rpm only. Results are always anonymized at creation: player names are rewritten to "Player 1"…"Player N" in the stored body the moment the review finishes, so no copy with real names ever exists server-side.
// 200 OK (201 Created when re-issuing after a revoke — a NEW id;
// the revoked one never comes back)
{ "share_id": "aZ3kQ9mB2xLp",
"url": "https://mjot.shinkuan.me/preview/s/aZ3kQ9mB2xLp",
"created_at": "2026-08-24T07:12:03Z", // the review's submit time
"anonymized": true }GET /v3/shared/{share_id}
No auth — anyone with the link, including you: this is the only route that serves a result body. The response is always gzip-compressed on the wire (every mainstream HTTP client inflates it automatically), cacheable, and CORS-open, so browser pages can fetch it directly. Unknown, revoked, and evicted ids are all 404. Rate limits: the per-IP bucket plus a global all-sources ceiling — under a sustained flood even a valid link can transiently answer 429; honor Retry-After and retry.
{ "status": "done", "model": "4p-ot2", "player_id": 2, "view": "full",
"share_id": "aZ3kQ9mB2xLp",
"created_at": "2026-08-24T07:12:03Z", // the review's submit time
"anonymized": true, // names are "Player N" in the stored body
"summary": { "n_decisions": 161, "n_match": 128,
"match_rate": 0.795, "avg_actual_prob": 0.62 },
"events": [ {"type": "start_game", "...": "..."} /* the full reviewed log */ ],
"decisions": [
{ "i": 42, // index into the returned 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 */ ]
}
] }| Field | Meaning |
|---|---|
| summary | n_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). |
| events | The exact stream the review replayed — the reviewed seat's censored perspective (other hands are "?"). The result is self-contained: render the game from it and overlay each decision at its index. For URL reviews this is the only copy of the converted log, so keep it if you need the game later. |
| decisions[].i | Index into the returned events array — the event this decision responds to. Use it to seek a replay viewer to the exact moment. |
| decisions[].ai / ai_label | The model's move as a full mjai event, plus its coarse label. |
| decisions[].actual / actual_label | Your move. actual_label "none" means you passed on a call opportunity (actual is null there). |
| decisions[].actual_prob / actual_rank | How the model rated your move: its softmax probability and its rank among the legal actions (1 = the model's top choice). |
| decisions[].candidates | Top-k candidates at that decision, capped by the submitting 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.
GET /v3/shares · DELETE /v3/shared/{share_id}
GET /v3/shares (auth) lists your live share links — one per stored done review, newest first — with each share's review_id, model, seat, and summary, so you can label a list without fetching bodies. DELETE /v3/shared/{share_id} (auth, creator key only) revokes a link — 204; a CDN may serve a cached copy for up to a few minutes. Revoking unpublishes the body for everyone — including you: the share URL was the only read path, so a revoked review has no readable body until you re-POST …/share, which issues a fresh link with a new id. The review itself stays stored. Both endpoints draw on the same per-key read bucket as the status poll (20/min, burst 40) — no rpm/rpd; a 404 revoke costs a token too. Both listing and revocation work with an expired key — what you published stays under your control after a subscription lapses.
Other endpoints
| Endpoint | Purpose |
|---|---|
| GET /v3/models | Lists 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/key | Your key's status: plan, expiry, usage today, rpd, rpm, topk, and the review meter (reviews_today / reviews_per_day). |
| POST /v3/redeem | Redeem 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 /healthz | Liveness + load (no auth): aggregate queue depth and worker health only — nothing about the model registry. |
Errors
All errors return {"error": "<message>"}:
| Status | Meaning / what to do |
|---|---|
| 400 | Malformed request (bad JSON, player_id out of range, bad events, body over limits). Fix the payload; do not retry unchanged. |
| 401 | Missing / unknown / expired / disabled key. Check the key; renew if expired. |
| 403 | Your plan may not use this model. Pick one from GET /v3/models. |
| 409 | review_not_done — you asked for a share link on a job that hasn't finished. State, not rate: wait for the job to finish, then retry. |
| 422 | The event stream could not be replayed (out of order, inconsistent, or illegal). Verify ordering and censoring. |
| 429 | Rate limit / daily quota / concurrency cap / too many auth failures. Honor the Retry-After header (seconds), then retry. |
| 503 | Temporarily 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.
| Label | Definition |
|---|---|
| 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. |
| reach | Declare riichi. This is only the declaration — which tile to cut is a separate follow-up decision (see Multi-step moves). |
| chi_low | Chi 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_mid | Chi where the called tile is the middle tile of the run — e.g. call 4m holding 3m + 5m. 4p only. |
| chi_high | Chi where the called tile is the highest tile of the run — e.g. call 5m holding 3m + 4m. 4p only. |
| pon | Call pon on the last discard (which tile is implied by the discard itself). |
| kan | Any 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. |
| hora | Win. Tsumo or ron is not distinguished by the label — in the reaction, target == actor means tsumo, otherwise ron off target. |
| ryukyoku | Declare kyuushu kyuuhai (nine terminals/honors abortive draw). Only ever legal on your first draw of an uninterrupted hand. |
| nukidora | Set aside a North tile as nukidora. 3p only; the reaction event type is kita. |
| none | Do 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_idrange is 0–2. Seat arrays may be native length-3 or 4p-padded length-4 — both are accepted.- Nukidora is the event type
kita(nukidoraaccepted 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
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
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
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.