---
name: mojiokoshi-kun-api
description: Build clients for 文字おこしくん (mojiokoshi-kun), a real-time per-speaker transcription service for Discord voice channels. Use when writing code that subscribes to live transcripts over the /v1/subscribe WebSocket (overlays, OBS captions, relay bots, archivers, analytics), sends audio over the /v1/ingest WebSocket, calls the HTTP API (list sessions, export transcripts, create transcript download links, mint session tokens, check usage) at https://moji.kaitoranma.com, receives mojiokoshi outgoing webhooks (session.started / session.ended / webhook.test, Standard Webhooks signatures with whsec_ secrets), or handles mojiokoshi session tokens, API keys (mjk_live_…), utterance revisions (partial/final/refined), reconnection, rate limits, or error codes.
---

# mojiokoshi-kun API skill

文字おこしくん = real-time, per-speaker transcription of Discord voice channels (and any audio you send). Docs are in Japanese; identifiers are English.

Base docs: https://moji.kaitoranma.com/docs/ (every page has a Markdown twin at the same path with `.md`).

## Always fetch the source of truth first

| What | URL |
|---|---|
| Overview for agents | https://moji.kaitoranma.com/docs/llms.txt |
| All docs in one file | https://moji.kaitoranma.com/docs/llms-full.txt |
| AsyncAPI 3.1 (both WebSockets) | https://moji.kaitoranma.com/docs/asyncapi.yaml |
| JSON Schema 2020-12 | https://moji.kaitoranma.com/docs/schemas/{common,ingest-client-message,ingest-server-message,subscribe-client-message,subscribe-server-message}.json |
| Error catalog | https://moji.kaitoranma.com/docs/errors.json |
| Reference client (JS, no deps) | https://moji.kaitoranma.com/docs/examples/mojiokoshi-subscribe.js |
| Reference client (Python, websockets) | https://moji.kaitoranma.com/docs/examples/mojiokoshi_subscribe.py |
| HTTP API (OpenAPI 3.1) | https://moji.kaitoranma.com/docs/openapi.yaml (human reference: https://moji.kaitoranma.com/docs/http.md) |
| HTTP client (TS / Python, no deps) | https://moji.kaitoranma.com/docs/examples/mojiokoshi-http.ts / mojiokoshi_http.py |
| Webhook reference (events, payloads, signature, retries) | https://moji.kaitoranma.com/docs/webhooks.md (payload schemas: the `webhooks` object of openapi.yaml) |
| Webhook verifier (TS / Python, no deps) | https://moji.kaitoranma.com/docs/examples/mojiokoshi-webhook.ts / mojiokoshi_webhook.py |

## Rules (do not break these)

1. **Credentials**: never put an API key (`mjk_live_…`) in browser code, OBS pages, mobile apps, logs, or repositories. Browsers/OBS get only a short-lived **session token** (`transcript:read` or `ingest:write`). API keys live on your server and mint session tokens via `POST /v1/sessions/:id/tokens`.
2. **Auth on WebSocket**: connect, then send `{"type":"hello","protocolVersion":1,"token":"…"}` as the first message. Never put the token in the WebSocket URL or headers. In page URLs, carry the token in the fragment (`#token=…`), not the query.
3. **Revisions replace, never append**: keep utterances in a map keyed by `utteranceId`; accept an `utterance` only if its `revision` is greater than the stored one. Stages go `partial` → `final` → `refined` (refined may never come). On `welcome`, replace the entire state with the snapshot (`speakers`, `tracks`, `speakingTrackIds`, `recent`). Sort by `startedAt`, then `utteranceId`.
4. **Reconnect with backoff, stop on fatal**: on unexpected close, reconnect after `min(30s, 0.5s × 2^n) × (1 − 0.5 × random())`; reset `n` after a connection that lived ≥10 s past `welcome`. Do **not** reconnect after `unauthorized`, `protocol_version_unsupported`, `session_ended`, `superseded`, `quota_exceeded`, a fatal `invalid_message`, or a `session_ended` message. Fatal `rate_limited` → wait 30 s. Unknown codes: accept them and follow `error.fatal` (fatal unknown = like `internal`). Never branch on WebSocket close codes.
5. **Liveness**: after `welcome`, send `{"type":"ping"}` every 15 s; if nothing at all arrives for ~10 s after a ping, close and reconnect.
6. **Forward compatibility**: ignore unknown fields; treat optional fields that are `null` as absent; reject unknown `type`s.
7. **Validate** messages with the published JSON Schemas in tests (Ajv 2020: `new Ajv2020({ strict: true })`, add all five schemas, validate by `$id`).
8. **Untrusted text**: `displayName` and `text` come from users/speech. Render with `textContent`; disable mentions when relaying to chat platforms.
9. **Ingest** (sending audio): one Ingest connection per session (a second one kicks the first with `superseded`). After `welcome`, send all `speaker_upsert`, all `track_open`, then `state_synced`, then audio. Binary frame = 7-byte big-endian header `[u8 0x01][u16 channel][u32 sequence]` + payload (one raw Opus packet at 48 kHz, or PCM s16le little-endian). Send one 20 ms frame at a time at real-time pace. Redo the whole handshake on every reconnect.
10. **Tests are deterministic**: fake WebSocket + fake timers for client logic; no tests against the production service.
11. **Webhooks** (registered by the Discord server's admin in the dashboard, not via the API): verify `webhook-signature` on the raw request body before parsing JSON (`v1,` + base64 HMAC-SHA256 of `${webhook-id}.${webhook-timestamp}.${body}`, key = base64-decoded part after `whsec_`), compare in constant time, accept if any space-separated `v1` signature matches (two are sent for 24 h after a secret rotation), and reject a `webhook-timestamp` more than 5 minutes off. Delivery is at-least-once and unordered: deduplicate by `webhook-id` and sort by the body's `createdAt` when order matters. Return 2xx quickly (15 s timeout) and do slow work afterwards; 3xx counts as failure; `410` stops deliveries to that endpoint. Ignore unknown event types and fields but still return 2xx. Events that happen while the endpoint is paused or stopped are never sent later, so reconcile with `GET /v1/sessions` when you must not miss a session. Keep the `whsec_…` secret server-side. On `session.started` mint a `transcript:read` token with an API key and subscribe (needed when the server does not store transcripts); on `session.ended` export the transcript.

## Minimal Subscribe client (JS)

```js
import { subscribe, sortedUtterances, speakerName } from "./mojiokoshi-subscribe.js"; // download from the URL above
subscribe({
  url: process.env.MOJIOKOSHI_SUBSCRIBE_URL, // wss://<host>/v1/subscribe
  token: process.env.MOJIOKOSHI_TOKEN,       // transcript:read session token
  onChange(state, message) {
    if (message.type === "utterance" && message.utterance.stage !== "partial")
      console.log(`${speakerName(state, message.utterance)}: ${message.utterance.text}`);
  },
  onStatus(status, detail) { if (status === "failed") console.error(detail); },
});
```

## Getting a token

- From the official overlay URL `https://<host>/#token=<TOKEN>&overlay=1`: in Discord, run `/link` while a transcription is running; the bot replies only to the caller with buttons that open the viewer page and the overlay. The `/start` reply is a notice visible to everyone and carries no viewer or overlay URL. In the voice channel's chat it has a 「文字起こしを見る」 button (opens the Discord Activity); elsewhere it links to the same notice in the voice channel's chat. The URL is not written in the message text: press the overlay button and copy the URL from the browser's address bar. WebSocket URL is `wss://<host>/v1/subscribe`.
- From an API key (server-side only; issued by a Discord server admin in the dashboard at `https://moji.kaitoranma.com/app/guilds/<guildId>/keys`): `GET https://moji.kaitoranma.com/v1/sessions?status=active&limit=1` then `POST /v1/sessions/{sessionId}/tokens {"scope":"transcript:read","ttlSeconds":3600}` with `Authorization: Bearer mjk_live_…` and an `Idempotency-Key` (UUID, reused on retry). API-key tokens last 1 h by default (max 24 h); mint a fresh one before reconnecting after expiry.
- Export after the session: `GET /v1/sessions/{sessionId}/transcript?format=json|text|csv` with `Authorization: Bearer <transcript:read token or API key>` (CSV: header `日時,話者,発言`, UTF-8 BOM, CRLF). To hand a transcript to people without credentials, create download links server-side: `POST /v1/sessions/{sessionId}/downloads {"ttlSeconds":3600}` with a `transcripts:read` API key returns `urls.{csv,json,text}` carrying a `transcript:download` token in the `token` query (export only; cannot be revoked, default 1 h, max 24 h, never past the record's retention), plus `chatgptUrl` (`GET /v1/sessions/{sessionId}/chatgpt?token=…`, a 302 redirect to ChatGPT with a summarize prompt and the transcript URL). Add `disposition=inline` to a `transcript:download` link to have browsers display the transcript instead of saving it. Records are deleted 30–365 days after the session ends, depending on the Discord server's level.
- HTTP errors are RFC 9457 problem details: branch on `code`. `429 rate_limited` → wait `Retry-After` seconds (limit 120 req/min per key); `quota_exceeded` (only on deployments with a daily limit; the official service has none) → wait until 00:00 UTC; other 4xx → fix the request. `402 credits_exhausted` is deprecated and no longer returned (since 2026-10-14; transcription is free). Paginate with `nextCursor` → `?cursor=`.

## Message cheat sheet

Subscribe server → client: `welcome`, `speaker_upserted`, `speaker_removed`, `track_opened`, `track_closed`, `speech_started`, `speech_ended`, `utterance`, `session_ended`, `error`, `pong`. Client → server: `hello`, `ping`.

Ingest client → server: `hello`, `speaker_upsert`, `speaker_remove`, `track_open`, `track_close`, `state_synced`, `speech_start`, `speech_end`, `ping`, plus binary audio frames. Server → client: `welcome`, `error`, `pong`.
