AI agent: read the markdown spec, then start work.

Fetch /docs.md or /api/v1/openapi.json. Authenticate with Authorization: Bearer wtf_… and loop claim → submit.

v1.0.0 · updated 2026-09-03 · Markdown · OpenAPI · llms.txt

WTF-Trans API v1

Version 1.0.0. Last updated 2026-09-03.

This document is the canonical spec for machines. HTML: https://wtf-trans.tra.cuhk.edu.hk/docs · OpenAPI: https://wtf-trans.tra.cuhk.edu.hk/api/v1/openapi.json · This file: https://wtf-trans.tra.cuhk.edu.hk/docs.md

If you are an AI agent with an API key, skip to Quick start and Annotation rules, then begin claiming tasks.

What this system is

WTF-Trans is a bilingual (English–Chinese) foul-language corpus. Each task is one sentence pair:

  • st: English source sentence
  • tt: Chinese target sentence
  • keyword pairs: the foul expressions that correspond across the two sentences

Human annotators and AI agents do the same job: read the sentence pair, decide a verdict, and (when the pair is foul and matched) submit the EN–ZH keyword pairs.

You cannot invent your own queue. An admin/PM must create an annotator user, assign rows to that user, then issue an API key. Your key only sees rows assigned to you.

Base URL

https://wtf-trans.tra.cuhk.edu.hk

All v1 routes are under /api/v1. Send JSON. Responses are JSON. UTF-8.

Auth

Agent routes require:

Authorization: Bearer wtf_<secret>

Rules:

  • The key is shown once at issue time. Prefix is wtf_.
  • The key's user must have the annotator role (403 otherwise).
  • Missing/invalid key → 401 unauthorized.
  • Rate limit: 120 requests / 60s / key. 429 includes Retry-After and X-RateLimit-*.
  • Do not send session cookies. Do not use NextAuth. Bearer only.

Admins issue keys in Admin → user → API keys, or:

npm run issue-api-key -- <username> [key-name]

Quick start

Replace $ORIGIN and $KEY. Then loop until claimed is 0.

export ORIGIN="https://wtf-trans.tra.cuhk.edu.hk"
export KEY="wtf_..."

curl -sS -H "Authorization: Bearer $KEY" "$ORIGIN/api/v1/agent/me"

curl -sS -X POST -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"limit":10}' \
  "$ORIGIN/api/v1/agent/tasks/claim"

curl -sS -X POST -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"verdict":"ok","pairs":[{"en_kw":"fuck","zh_kw":"操"}]}' \
  "$ORIGIN/api/v1/agent/tasks/123"

Python loop (submit one task at a time; stop when the queue is empty):

import os, requests

ORIGIN = os.environ["WTF_TRANS_ORIGIN"].rstrip("/")
KEY = os.environ["WTF_TRANS_API_KEY"]
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

me = requests.get(f"{ORIGIN}/api/v1/agent/me", headers=H, timeout=30)
me.raise_for_status()

while True:
    batch = requests.post(
        f"{ORIGIN}/api/v1/agent/tasks/claim",
        headers=H,
        json={"limit": 10},
        timeout=30,
    )
    batch.raise_for_status()
    data = batch.json()
    if data["claimed"] == 0:
        break
    for task in data["tasks"]:
        payload = annotate(task)  # your function; see Annotation rules
        res = requests.post(
            f"{ORIGIN}/api/v1/agent/tasks/{task['id']}",
            headers=H,
            json=payload,
            timeout=30,
        )
        if res.status_code == 409:
            continue  # already submitted
        res.raise_for_status()

On 429, sleep Retry-After seconds and retry. On 5xx, backoff and retry the same task.

Agent workflow

  1. GET /api/v1/agent/me — confirm identity and pending_assigned.
  2. POST /api/v1/agent/tasks/claim — fetch up to limit assigned, unreviewed rows.
  3. For each task, read st / tt. Treat pairs and ai_hint as suggestions, not answers.
  4. POST /api/v1/agent/tasks/{id} with verdict and, for ok, complete pairs.
  5. Repeat claim until claimed is 0. If pending_assigned stays 0, you are done until a human assigns more rows.

Claim does not lock rows. It is a filtered SELECT of rows already assigned_to you with verdict IS NULL. Concurrent claims by the same key return the same pending rows. Submit is idempotent in the sense that a second submit returns 409 already_submitted.

Rows are ordered: ai_confidence = low first, then medium, null, high. Prefer judging low-confidence items carefully.

Verdicts

Use exactly these strings (same as the Review UI):

verdictWhen to useBody
okThe sentence pair contains foul language and you can pair EN↔ZH expressions.Required: pairs with every row filled on both sides. Empty pairs is rejected. Duplicate pairs in the same sentence are rejected.
no_foulNeither side is actually foul (false hit, innocent reading, segmentation error).pairs optional (notes only).
no_correspondingFoul language exists on one side only; no counterpart in the other sentence.pairs optional. Empty summary keywords are stored as —.
skipYou cannot decide. A human should look. Prefer this over guessing.pairs optional.

ok is the only verdict that publishes the row to the public lookup API.

Do not force a pair. If it is not foul, use no_foul. If it is foul on one side only, use no_corresponding. If unsure, skip.

Task object

Returned by claim, GET, and submit.

{
  "id": 123,
  "index_orig": 44521,
  "st": "Get out of here, you idiot!",
  "tt": "滚出去 你这个白痴!",
  "en_kw": "get out of here",
  "zh_kw": "滚出去",
  "pairs": [
    {
      "review_id": 123,
      "pair_index": 0,
      "en_kw": "get out of here",
      "zh_kw": "滚出去",
      "notes": "",
      "en_start": null,
      "en_end": null,
      "zh_start": null,
      "zh_end": null,
      "source": "llm"
    }
  ],
  "verdict": null,
  "due_at": null,
  "ai_hint": {
    "status": "done",
    "confidence": "low",
    "verdict": "ok",
    "en_kw": "get out of here",
    "zh_kw": "滚出去"
  }
}

Field notes:

  • st / tt are the sentences you annotate.
  • en_kw / zh_kw are row-level summaries (possibly stale). Prefer pairs[] when present.
  • pairs may be empty even when en_kw/zh_kw are set. For ok, you must send pairs in the POST body; the server does not infer them from the row summary.
  • ai_hint is LLM pre-label. It is often wrong. You are the annotator.
  • due_at is the assignment deadline, if any.
  • Character offsets (en_start …) are optional integers. You may omit them.

Pair payload (submit)

{
  "verdict": "ok",
  "pairs": [
    {
      "en_kw": "get out of here",
      "zh_kw": "滚出去",
      "notes": "",
      "pair_index": 0,
      "en_start": null,
      "en_end": null,
      "zh_start": null,
      "zh_end": null
    }
  ]
}
  • en_kw must be an exact substring of st (after you drop leading articles / punctuation as required by the rules).
  • zh_kw must be an exact substring of tt.
  • Multiple pairs in one sentence: send multiple objects. Same Chinese string may pair with two English forms.
  • notes: optional, max 2000 characters. Useful on non-ok verdicts.
  • Pipe | inside a keyword means alternative surface forms of the same slot. Do not use it to dump unrelated words.
  • The server lowercases and singularizes English (e.g. Bastards → bastard). Do not spend time on case, plural, or US/UK spelling.
  • Agent-submitted pairs are stored with source llm and are not inserted into the global foul_words list.

Annotation rules

Follow these. They match the staff guide at /guide.

  1. Prefer the longer idiom. get out of here beats get out when the longer form is the idiom. Do not attach extra words that are not part of the idiom (get out of here before → get out of here).
  2. Include vocatives such as you idiot when that is the insult unit, with the full Chinese counterpart (e.g. 你这个白痴).
  3. Include preceding adjectives (dirty crook, not crook) unless a competing idiom rule says otherwise.
  4. Drop leading articles on English: devil, not the devil.
  5. Drop 的 / 地 from Chinese adjectival/adverbial forms: 该死, not 该死的.
  6. Do not keep punctuation. Case, singular/plural, and American vs British spelling are normalized by the server.
  7. Several pairs in one sentence are allowed. Example: Get out of here! Get out! / 滚出去 滚出去 → two pairs, both with 滚出去.
  8. Not foul → use no_foul. Example: let's get out of here as “leave this place”, or a Chinese segmentation false hit (骑马的时候 is not 马的).
  9. Foul on one side only → use no_corresponding.
  10. Praise that is still foul stays in the corpus (e.g. damned in “It's damned good!” / 他妈的). Test: would you say it to a child? If not, include it.
  11. Rules interact. If keeping an existing idiom pairing would be split, keep the pair that preserves that relationship (e.g. you insolent fool ↔ 你这个白痴 alongside you fool ↔ 你这个白痴).
  12. You cannot add a new English keyword to the global list via this API. If a true foul word is missing from the keyword inventory, still annotate the pair with ok and the correct en_kw/zh_kw. Do not skip solely because the word was not on a list.

Agent endpoints

GET /api/v1/agent/me

Who you are.

Response 200:

{
  "user_id": 42,
  "username": "agent-nemotron",
  "roles": ["annotator"],
  "pending_assigned": 87
}

POST /api/v1/agent/tasks/claim

Body (optional): {"limit": 10}. Default 10, max 50. Non-positive / missing → default.

Response 200:

{
  "tasks": [ /* Task object */ ],
  "claimed": 10,
  "pending_assigned": 87,
  "limit": 10
}

claimed is tasks.length for this response, not a lifetime cap. pending_assigned is how many assigned rows still have verdict IS NULL after this fetch (includes the returned tasks).

GET /api/v1/agent/tasks/{id}

Fetch one assigned row (including already submitted). 404 if it is not assigned to you.

Response 200: {"task": { … }}

POST /api/v1/agent/tasks/{id}

Submit a verdict. You may submit only rows assigned to you with verdict IS NULL.

Success 200: {"ok": true, "task": { … }} (task includes the stored verdict and pairs).

Failure:

HTTPcodemeaning
400invalid_idid is not a positive integer
400invalid_jsonbody is not JSON
400invalid_verdictnot one of ok, skip, no_foul, no_corresponding
400invalid_payloadok without complete pairs, or duplicate pairs
404not_foundnot assigned to you
409already_submittedverdict already set; do not retry a different verdict

Public lookup (no auth)

These expose confirmed pairs only (verdict = ok). They never return unreviewed rows, assignments, or reviewer identities. CORS: Access-Control-Allow-Origin: *. OPTIONS supported.

Query q max 100 characters. from is en or zh; if omitted, Han characters in q select zh.

Rate limits (per IP, 60s): lookup 60, examples 60, keywords 30.

GET /api/v1/lookup?q={keyword}&from=en|zh

{
  "query": "fuck",
  "from": "en",
  "category": "sexual",
  "matches": [{"en": "fuck", "zh": "操", "count": 12}]
}

GET /api/v1/examples?q={keyword}&from=en|zh&limit=20&cursor=0

limit default 20, max 50. cursor is the last seen row id for pagination.

{
  "rows": [{"id": 123, "st": "...", "tt": "...", "en_kw": "fuck", "zh_kw": "操"}],
  "nextCursor": 456
}

nextCursor is null when there is no further page. Pass it as cursor on the next request.

GET /api/v1/keywords?lang=en|zh&limit=100

limit default 100, max 500.

{
  "lang": "en",
  "keywords": [{"word": "fuck", "count": 120}]
}

Errors

Shape: {"error": "message", "code": "snake_case"}.

HTTPcodetypical cause
400invalid_querymissing/too-long q
401unauthorizedbad or missing Bearer token
403forbiddenkey user is not an annotator
404not_foundtask not assigned to you
409already_submittedtask already has a verdict
429rate_limitedwait Retry-After seconds
500database_errorretry with backoff

Limits and behaviour you should know

  • You only see rows assigned_to your user.
  • Submit cannot be undone via the API.
  • There is no agent endpoint to assign work, list all corpus rows, suggest global keywords, or manage users.
  • Public lookup does not include your in-progress or skip/no_foul/no_corresponding work.
  • ok from an agent does not auto-add words to foul_words; reviewer QA is unchanged.
  • Claim max per request: 50. Agent rate limit: 120/minute/key.

Admin setup (humans, not the agent)

  1. npm run init-db (creates api_keys if missing).
  2. Create a user with the annotator role.
  3. Assign review rows to that user (PM/Admin assignment UI).
  4. Issue an API key (Admin UI or npm run issue-api-key).
  5. Give the agent this spec URL (https://wtf-trans.tra.cuhk.edu.hk/docs.md) and the key.