# IPA API docs

Generated from the OpenAPI document. One Markdown page per section.

# IPA API

Score a Koine Greek reading. Agents start at `POST /mcp` (MCP Streamable HTTP, no SSE). Tools: `score`, `heard_as`, `recognize`, `create_upload`. `score_attempt` is an alias of `score`.

`score` takes a Greek `target` plus `ipa`, a small `audio_base64` WAV, or `audio_url`. `ipa` wins when it is also sent. The response is `tutor-v1` JSON. `proximity` runs from 0 to 1. For λόγος heard as `l o g o s` it is 1.0. `heard_as` returns that ranking only and does not change `proximity`. `recognize` turns WAV bytes into phones and does not score. Chat, Claude, and Gemini pass JSON. They do not attach a microphone.

The JSON names each sound, a feature diff, an error code, stress, and a ranked `heard_as` list. It does not include a pronunciation tip.

Public host: [https://ipa.raymati.com](https://ipa.raymati.com). Local IPA scoring: `http://127.0.0.1:43117`.

## For agents

- [Install](/docs/install.md) has the client snippets
- [llms.txt](/llms.txt) is the index
- [OpenAPI 3.1](/openapi.json) has the operations, schemas, and scored examples
- [MCP server.json](/server.json) is `com.raymati/ipa` at `/mcp`. [/server-card.json](/server-card.json) is the same document
- [Agent card](/.well-known/agent-card.json) is the A2A discovery file. This host speaks MCP, not an A2A task API
- [Registries](/docs/registries.md) and [Submitting](/docs/submitting.md) are the steps after deploy. This repo does not submit

An HTML page also answers `Accept: text/markdown` with its Markdown sibling. That negotiation is a draft. The `.md` URL stays available either way.

## Read next

- [Quickstart](/docs/quickstart.md) has curl, Python, JavaScript, and Rust
- [Install](/docs/install.md) connects ChatGPT, Claude, Cursor, VS Code, Gemini CLI, and Claude Code
- [Concepts](/docs/concepts.md) defines proximity, status, stress, and heard_as
- [Errors](/docs/errors.md) is RFC 9457 plus the rate-limit headers
- [Reference](/docs/reference.md) is the schema, field by field
- [Changelog](/docs/changelog.md)

# Guides

Everything under `/docs` is generated from `service/tutor-worker/openapi.yaml`. Response numbers come from the scorer at generation time. A CI check rebuilds the bundle and fails if a byte moved.

- [Quickstart](/docs/quickstart.md)
- [Install](/docs/install.md) connects the clients. It also documents `ALLOW_TRAINING_CRAWLERS`
- [Auth](/docs/auth.md) says an `ipa_live_` key is required to score, and how to request one
- [Concepts](/docs/concepts.md)
- [Errors](/docs/errors.md)
- [Reference](/docs/reference.md)
- [Changelog](/docs/changelog.md)
- [Registries](/docs/registries.md) is the official MCP registry (DNS, `com.raymati/ipa`). Nothing here is submitted automatically
- [Submitting](/docs/submitting.md) is Claude, ChatGPT, Smithery, Glama, PulseMCP, mcp.so, and APIs.guru. Do not submit from this repo
- [Privacy](/privacy.md) and [Terms](/terms.md) are placeholders for Aaron to review
- [Record widget](/apps/record) is the MCP Apps stub
- [Example pairs](/docs/examples.json)
- [OpenAPI 3.1](/openapi.json)
- [llms.txt](/llms.txt)

# Quickstart

`POST /v1/tutor` with `Content-Type: application/json`. An IPA request does not need the recognizer. `ipa` wins if you also send audio.

Audio is JSON. `audio_base64` is a WAV of at most 256 KiB decoded. A larger clip calls the MCP tool `create_upload`, PUTs the wav, and passes `audio_url`. Client setup is on [Install](/docs/install.md).

The path is `/v1`. The success field `api` is `tutor-v1`. Package `0.2.0` names the build, not the score contract. New fields may appear without a new path. A break in the score contract would be `/v2`.

## curl

```bash
curl -sS https://ipa.raymati.com/v1/tutor \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer ipa_live_...' \
  -d '{"target":"λόγος","ipa":"l o g o s","scheme":"koine-greek","different_word":false,"calibrate":true}'
```

## Python

The standard library is enough.

```python
import json
from urllib.request import Request, urlopen

body = json.loads(r"""{
  "target": "λόγος",
  "ipa": "l o g o s",
  "scheme": "koine-greek",
  "different_word": false,
  "calibrate": true
}""")
request = Request(
    "https://ipa.raymati.com/v1/tutor",
    data=json.dumps(body).encode(),
    headers={"content-type": "application/json", "authorization": "Bearer ipa_live_..."},
)
with urlopen(request) as response:
    score = json.load(response)
print(score["proximity"])

```

## JavaScript

```javascript
const body = {
  "target": "λόγος",
  "ipa": "l o g o s",
  "scheme": "koine-greek",
  "different_word": false,
  "calibrate": true
};
const response = await fetch("https://ipa.raymati.com/v1/tutor", {
  method: "POST",
  headers: {"content-type": "application/json", "authorization": "Bearer ipa_live_..."},
  body: JSON.stringify(body),
});
const score = await response.json();
console.log(score.proximity);

```

## Rust

No extra crate. The snippet shells out to curl.

```rust
fn main() {
    let body = r#"{"target":"λόγος","ipa":"l o g o s","scheme":"koine-greek","different_word":false,"calibrate":true}"#;
    let output = std::process::Command::new("curl")
        .args([
            "-sS",
            "https://ipa.raymati.com/v1/tutor",
            "-H",
            "content-type: application/json",
            "-H",
            "authorization: Bearer ipa_live_...",
            "-d",
            body,
        ])
        .output()
        .expect("curl");
    println!("{}", String::from_utf8(output.stdout).expect("utf-8"));
}

```

## What you get back

λόγος / `l o g o s` (example `exactLogos`):

- `proximity` 1.0
- `real_edits` 0
- `phones[].status` `correct`, `correct`, `correct`, `correct`, `correct`
- `coaching.sounds[].status` `exact`, `exact`, `exact`, `exact`, `exact`
- `stress.penalty` 0.0
- `heard_as.candidates[0]` λόγος (lexicon), confidence 0.715876

The full object is the `exactLogos` response example in [openapi.json](/openapi.json). Other public calls:

| Example | IPA | proximity | real_edits | first learner status |
| --- | --- | --- | --- | --- |
| `exactLogos` | `l o g o s` | 1.0 | 0 | `exact` |
| `stressShift` | `l o g ˈo s` | 0.9 | 0 | `exact` |
| `thetaAsS` | `s e o s` | 0.75 | 1 | `close` |
| `acceptableTheta` | `tʰ e o s` | 0.988187 | 0 | `acceptable_variant` |
| `verseArche` | `e n a r x i` | 1.0 | 0 | `exact` |

Local replacement for the host: `http://127.0.0.1:43117`. Set `IPA_TUTOR_BIND` to move it.

## MCP

`POST /mcp` speaks [Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) as one JSON object, not an SSE stream.

- `score` takes the same IPA fields and returns `tutor-v1` in `structuredContent`. `score_attempt` is the same tool.
- `heard_as` takes `target` and `ipa` and returns only the `heard_as` object. The ranking does not change `proximity`.
- `recognize` takes `audio_base64` of a WAV. It returns phones. It does not score. No sample wav is published.

```bash
curl -sS https://ipa.raymati.com/mcp \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer ipa_live_...' \
  -H 'MCP-Protocol-Version: 2025-03-26' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"score","arguments":{"target":"λόγος","ipa":"l o g o s","scheme":"koine-greek","different_word":false,"calibrate":true}}}'
```

A missing `MCP-Protocol-Version` header is treated as `2025-03-26`. `2026-07-28` is also accepted. `GET /mcp` is 405.

HTML guides honor `Accept: text/markdown` (draft-consolidated-content-01). `curl -H 'Accept: text/markdown' https://ipa.raymati.com/docs/quickstart` returns this page. The `.md` URL does the same without negotiation.

# Concepts

Targets in the examples are short forms from the SBL Greek New Testament (CC BY-SA 4.0): λόγος, θεός, ἐν ἀρχῇ. The calls are IPA strings. There is no audio in the documented examples.

## Proximity

> Calibrated score after the stress penalty. Unchanged by coaching and heard_as.

An exact phone match with no stress penalty is 1.0 (`exactLogos`). `real_edits` counts phones whose scoring status is `incorrect`, `missing`, or `extra`. It is 0 on that call and 1 when θεός is heard as `s e o s`.

The Python package and the Rust worker agree on these examples within 0.000005. That is the same tolerance the golden tests use.

`acceptable variant` (cost 0.02) is not a real edit. θεός heard as `tʰ e o s` has `phones[0].status` `acceptable variant`, `real_edits` 0, and `proximity` 0.988187.

A stress penalty of 0.10 is subtracted once, after the phone score, when it applies. λόγος heard as `l o g ˈo s` has `proximity` 0.9 and `real_edits` 0. `coaching` and `heard_as` do not change `proximity`. Frame GOP does not change it either. Duration is not in the score.

## Scoring status and learner status

`phones[].status` is the alignment label. The enum is `correct`, `acceptable`, `acceptable variant`, `incorrect`, `missing`, `extra`.

`coaching.sounds[].status` is the learner label:

> exact matches scoring status correct. acceptable_variant matches scoring status "acceptable variant" (the 0.02 taught-versus-historical pairs). close is scoring status acceptable, or an incorrect substitution whose closeness is at least 0.5, or a far substitution whose low_confidence flag is true. far is an incorrect substitution whose closeness is below 0.5 and whose confidence is not low. missing and extra match those scoring statuses.

On `thetaAsS`, the first sound is `close` with `error_code` `dental_fricative_to_alveolar_sibilant` and closeness 0.875. The scoring status of that phone is `incorrect`.

## Feature diffs and error codes

> Stable code for this phone difference. Null when status is exact. A consonant substitution names place and manner. Fricatives s, z, ʃ, and ʒ use sibilant in the code, so θ→s is dental_fricative_to_alveolar_sibilant. Voice, aspiration, and length words appear only when that feature differs. A vowel substitution names height, backness, and rounding on both sides. Missing and extra prefix the same token. Low confidence does not change the code.

> Features that differ, in order: kind, place, manner, voicing, aspiration, rounding, height, backness, length. Empty when status is exact.

Feature names, in order: `kind`, `place`, `manner`, `voicing`, `aspiration`, `rounding`, `height`, `backness`, `length`. The first sound of `thetaAsS` differs in:

```json
[
  {
    "feature": "place",
    "target": "dental",
    "heard": "alveolar"
  }
]
```

`coaching.summary.fixes` lists at most three repairs with `score_gain` above 0. The code on a fix is the same `error_code`. There is no learner sentence in the JSON.

## Stress

The penalty is 0.10 once per attempt. Monosyllables, unaccented words, and words whose only accent is varia are not penalized. A primary `ˈ` in the IPA selects the attempt syllable (`stress.method` `mark`), including a mark on a verse. Secondary `ˌ` is ignored. The phone alignment drops the mark, so `real_edits` does not move.

`stressShift` puts `ˈ` on the second syllable of λόγος. The written accent is the first.

- `stress.method` `mark`
- `stress.penalty` 0.1
- `stress.match` false
- `coaching.stress.error_code` `stress_shift`
- `coaching.stress.target_letters` λό
- `coaching.stress.detected_letters` γος

With no `ˈ` and no audio, `stress.method` is `unavailable` and `match` is null (`exactLogos`). Audio without a mark uses an energy share and a pitch share. A gap under 0.04 abstains (`match` null, penalty 0). A multi-word clip that does not frame-align does not pay 0.10. `match` can still be false when a compared polysyllable disagrees. A single word, and a `ˈ` mark, still pay 0.10.

## heard_as

> posterior when at least one candidate received a CTC forward score from the request posteriors. distance-softmax otherwise. Both paths order candidates with the same softmax. Published confidence is the Platt map, not that mass.

> Softmax temperature used to order candidates. The shipped value is 0.1. Published confidence is a separate Platt map of the top-1 gap, the normalized tutor cost, and a non-word indicator.

Rank order is the temperature-0.1 softmax. Published `confidence` is the Platt map, not that mass. Candidates sum to at most 1. `other` is a candidate kind. A verse puts one ranking in `heard_as.words` per target word. `verseArche` (`ἐν ἀρχῇ`, `e n a r x i`) has proximity 1.0, `real_edits` 0, and 2 word rankings.

`different_word` reports the closest one-edit neighbor. It does not change `proximity`. The examples send `different_word: false`, so `checked` is false.

## Versioning

`/v1` plus `api: tutor-v1` is the score contract. Adding a field is not a break. Removing or renaming one would be `/v2`. Error bodies changed from plain text to `application/problem+json` without a new path; `detail` still carries the old sentence. See [Errors](/docs/errors.md).

# Errors

Failed calls use `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)).

```json
{
  "type": "https://ipa.raymati.com/docs/errors#bad-request",
  "title": "Bad Request",
  "status": 400,
  "detail": "request must be a JSON object",
  "instance": "/v1/tutor"
}
```

`type` is a section of this page. `detail` is one sentence. Success JSON is not a problem document.

## Bad request

Status 400. The body was not a JSON object, the target was empty, the audio was not a wav, or neither `ipa` nor audio was usable. `detail` is `request must be a JSON object`, `target must be a Greek word or verse`, or `send ipa, audio_base64, or audio_url`, among the scorer's own sentences. Inline audio over 256 KiB says to call `create_upload`.

## Not found

Status 404. `detail` is `not found`.

## Method not allowed

Status 405. `Allow` names the methods that path accepts. `GET /mcp` is 405 because this server does not open an SSE stream.

## Not acceptable

Status 406. `POST /mcp` received an `Accept` header that allows neither `application/json` nor `text/event-stream`.

HTML guides are different. `Accept: text/markdown` is a 200 Markdown body (draft-consolidated-content-01), not a 406. `*/*` stays HTML.

## Too many requests

Status 429. `detail` is `rate limit exceeded`. `code` is `rate-limit-exceeded`.

`POST /v1/tutor`, `POST /v1/bench`, `PUT` and `GET /v1/uploads/{id}`, and `POST /mcp` with method `tools/call` require an `ipa_live_` key and count toward 120 requests per 60 seconds for that key. Send `Authorization: Bearer` or `X-API-Key`. A missing key is 401 `api-key-required` with `WWW-Authenticate: Bearer realm="https://ipa.raymati.com"`. An unrecognized key is 401 `api-key-rejected`. The Workers Rate Limiting binding is `TUTOR_KEY` (`namespace_id` `471702`, limit 120, period 60). An in-memory window is the fallback for local tests and the native process. GET pages, `/health`, `/openapi.json`, `llms.txt`, `/.well-known/*`, `robots.txt`, the sitemap, logo assets, `initialize`, and `tools/list` stay public and are not counted. Request a key from the operator at https://ipa.raymati.com/docs. `scripts/mint_key --label <name>` prints the secret once. The store keeps a SHA-256 hash, a label, a short prefix, and audio versus IPA counters. No route lists secrets.

A POST body over 400 KiB is status 413, `code` `body-too-large`, and is refused from `Content-Length` before the body is read. `PUT /v1/uploads/{id}` uses the 8 MiB upload cap the same way.

`create_upload` keeps 32 slots. When they are all still live the response is status 503, `code` `upload-slots-full`, and `Retry-After` is the seconds until the oldest slot expires. The call does not drop another caller's wav.

The headers follow [draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers), which is still a draft:

```http
RateLimit-Policy: "tutor";q=10;w=60
RateLimit: "tutor";r=9;t=60
```

A key sends `RateLimit-Policy: "tutor";q=120;w=60` instead.

`q` is the quota, `w` is the window in seconds, `r` is how many requests remain, and `t` is whole seconds until the oldest counted request leaves the window. A 429 also sets `Retry-After` to `t`.

## Bad gateway

Status 502. The recognizer returned an error. `detail` starts with `recognizer returned`.

## Service unavailable

Status 503. Audio was sent and neither `RECOGNIZER_URL` nor the `ZIPA` service binding is available. An IPA request does not need either. `detail` on the worker is `RECOGNIZER_URL is unset and the ZIPA service binding is missing. IPA requests do not need either.` The native process says `RECOGNIZER_URL is empty` when that variable is blank.

A warming ZIPA container is also 503. `detail` starts with `recognizer returned 503` and the response sets `Retry-After`. Retry that request. It is not a 502.

`create_upload` slot exhaustion is 503 with `code` `upload-slots-full`.

## Audio URL

`audio_url` is https, or an upload from `create_upload`. The worker rejects `http`, userinfo, and hosts that are localhost, `*.local`, `*.internal`, or an IP literal in a private, loopback, link-local, multicast, or unspecified range, including IPv4-mapped forms of those addresses. It follows at most 3 redirects and checks every hop. Connect budget is 5 seconds when the transport reports it. The total budget is 20 seconds. The body is read in chunks and aborted at 256 KiB, the same cap as decoded `audio_base64`. `Content-Type` must be `audio/wav`, `audio/x-wav`, `audio/wave`, or `audio/vnd.wave`.

The `code` values are `audio-url-not-https`, `audio-url-host`, `audio-url-userinfo`, `audio-url-redirect-host`, `audio-url-redirects`, `audio-url-status`, `audio-url-type`, `audio-url-too-large`, `audio-url-connect`, and `audio-url-timeout`. The body is `application/problem+json`.

## Unscored audio

Audio shorter than 0.12 s after the trim is HTTP 200. `proximity` is null and `unscored` is `too_short`. That is not a problem document.

# Reference

Generated from `components.schemas` and `components.headers`. Descriptions are the schema text.

## Operations

- `GET /health` `getHealth` Ready check
- `POST /v1/tutor` `scoreTutor` Score a Koine word or short verse
- `POST /mcp` `callMcp` MCP Streamable HTTP (JSON, not SSE)
- `PUT /v1/uploads/{id}` `putUpload` Store one WAV on the create_upload slot
- `GET /v1/uploads/{id}` `getUpload` Read a stored WAV

## Examples

| Name | Target | IPA | proximity | real_edits |
| --- | --- | --- | --- | --- |
| `exactLogos` | λόγος | `l o g o s` | 1.0 | 0 |
| `stressShift` | λόγος | `l o g ˈo s` | 0.9 | 0 |
| `thetaAsS` | θεός | `s e o s` | 0.75 | 1 |
| `acceptableTheta` | θεός | `tʰ e o s` | 0.988187 | 0 |
| `verseArche` | ἐν ἀρχῇ | `e n a r x i` | 1.0 | 0 |

Full responses are the matching examples on `POST /v1/tutor` in [openapi.json](/openapi.json).

## TutorRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `target` | string | yes | Greek word or short verse. |
| `ipa` | string / array of string |  | Learner phones. A string is tokenized. Explicit ipa wins over audio_base64 and audio_url. |
| `audio_base64` | string |  | WAV bytes, base64. At most 256 KiB decoded. Larger clips use create_upload and audio_url. Resampled to 16 kHz mono. Under 0.12 s is unscored. |
| `audio_url` | string |  | audio_url from the MCP tool create_upload, or an https URL. An upload id is read from this process. Other URLs must be https. The worker rejects userinfo, IP literals that are private, loopback, link-local, or otherwise non-public, and names such as localhost. It follows at most 3 redirects and checks each hop the same way. The body is read until 256 KiB, the same cap as audio_base64, then aborted. Content-Type must be audio/wav, audio/x-wav, audio/wave, or audio/vnd.wave. Failures are problem+json with a stable `code` (`audio-url-not-https`, `audio-url-host`, `audio-url-redirects`, `audio-url-too-large`, `audio-url-type`, `audio-url-timeout`). |
| `scheme` | string |  |  |
| `different_word` | boolean |  |  |
| `calibrate` | boolean |  |  |
| `thresholds` | object |  | Map of name to minimum, or a list of minimums. |
| `posteriors` | array of array of number |  | CTC frame log-probabilities. With vocab, each target phone gets a confidence. |
| `vocab` | array of string |  |  |
| `blank_id` | integer |  |  |
| `recognizer` | string |  |  |

## TutorResponse

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `api` | enum | yes |  |
| `scheme` | string | yes |  |
| `target_text` | string |  |  |
| `target_words` | array of object |  |  |
| `observed_phones` | array of string |  |  |
| `proximity` | number | yes | Calibrated score after the stress penalty. Unchanged by coaching and heard_as. |
| `lenient_similarity` | number |  |  |
| `strict_similarity` | number |  |  |
| `real_edits` | integer | yes | Count of phones whose scoring status is incorrect, missing, or extra. |
| `phones` | array of `ScoredPhone` | yes |  |
| `different_word` | `DifferentWord` |  |  |
| `stress` | object | yes | Scoring stress object. The penalty is already inside proximity. |
| `gop` | object |  | Present when the request carried posteriors. |
| `thresholds` | array of object |  |  |
| `coaching` | `Coaching` | yes |  |
| `heard_as` | `HeardAs` | yes |  |
| `input_quality` | object |  | Present when the request carried audio. Measured before resampling. Does not change proximity. Absent on IPA examples. |

## ScoredPhone

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `target` | string or null |  |  |
| `observed` | string or null |  |  |
| `status` | enum | yes | Scoring label. This is what real_edits counts. It is not the learner label. |
| `cost` | number | yes |  |
| `confidence` | number or null |  | Calibrated GOP confidence. Null when the request has no posteriors. |
| `uncertain` | boolean |  | True when confidence is below 0.75 or alignment failed. |

## Coaching

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `summary` | `CoachingSummary` | yes |  |
| `sounds` | array of `CoachingSound` | yes |  |
| `stress` | `CoachingStress` | yes |  |

## CoachingSummary

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `proximity` | number | yes | Same rounded value as the response proximity. |
| `fixes` | array of `CoachingFix` | yes | Repairs with score_gain above 0, at most three. Ranked by score_gain descending, then a sound fix before a stress fix, then sound_index. |

## CoachingFix

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `rank` | integer | yes |  |
| `kind` | enum | yes |  |
| `sound_index` | integer |  | Present when kind is sound. Index into coaching.sounds and phones. |
| `word` | string or null |  |  |
| `letters` | string or null |  |  |
| `target` | string or null |  |  |
| `heard` | string or null |  |  |
| `status` | `LearnerStatus` |  |  |
| `target_syllable` | integer or null |  | Present when kind is stress. |
| `detected_syllable` | integer or null |  |  |
| `target_letters` | string or null |  | Greek letters of the target syllable. Present when kind is stress. |
| `detected_letters` | string or null |  |  |
| `error_code` | string | yes | Same code as the sound, or stress_shift. |
| `score_gain` | number | yes | Proximity restored by fixing this one sound, or by clearing the stress penalty. |

## CoachingSound

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `index` | integer | yes |  |
| `word` | string or null |  |  |
| `letters` | string or null |  | Greek letters that produced the target phone. Accents stay. Null on an extra. |
| `syllable` | integer or null |  | Syllable index inside the word. The stress object uses the verse-level index. |
| `target` | string or null |  | Lexicon phone, IPA. |
| `heard` | string or null |  | Aligned attempt phone, IPA. Null when the target phone is missing. |
| `status` | `LearnerStatus` | yes |  |
| `closeness` | number | yes | Articulatory closeness. Exact is 1. Missing and extra are 0. Vowels: 1 - min(1, height/6 + 0.5·backness/2 + 0.5·|rounding|). Consonants: 1 - min(1, place/8 + 0.5·manner/7 + 0.5·|voice|). A vowel against a consonant is 0. Length alone is 0.9. Aspiration alone is 0.85. Rounded to 6 decimal places. Low confidence does not change this number. |
| `low_confidence` | boolean | yes | True when the aligned phone row has uncertain true, or confidence is a number below 0.75. An IPA-only phone with null confidence is false. When this is true and the feature label would be far, status is close. The error code and the feature diff stay on the phones. An exact phone stays exact. |
| `error_code` | string or null | yes | Stable code for this phone difference. Null when status is exact. A consonant substitution names place and manner. Fricatives s, z, ʃ, and ʒ use sibilant in the code, so θ→s is dental_fricative_to_alveolar_sibilant. Voice, aspiration, and length words appear only when that feature differs. A vowel substitution names height, backness, and rounding on both sides. Missing and extra prefix the same token. Low confidence does not change the code. |
| `feature_diff` | array of `FeatureDiff` | yes | Features that differ, in order: kind, place, manner, voicing, aspiration, rounding, height, backness, length. Empty when status is exact. |

## FeatureDiff

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `feature` | enum | yes |  |
| `target` | string or null | yes | Label on the lexicon phone. Null on an extra, or when that side has no such feature. |
| `heard` | string or null | yes | Label on the attempt phone. Null when the target phone is missing. |

## CoachingStress

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `target_syllable` | integer or null | yes | Accented syllable, same index as stress.target_syllable. |
| `detected_syllable` | integer or null | yes | Attempt syllable, same index as stress.attempt_syllable. |
| `match` | boolean or null | yes | Null when stress abstains or the attempt has no accent mark. |
| `target_letters` | string or null | yes | Greek letters of the target syllable, accents kept. Null when the index is null. |
| `detected_letters` | string or null | yes | Greek letters of the detected syllable. Null when the index is null. |
| `error_code` | string or null | yes | stress_shift when match is false. Null when match is true or null. |

## LearnerStatus

exact matches scoring status correct.
acceptable_variant matches scoring status "acceptable variant" (the 0.02 taught-versus-historical pairs).
close is scoring status acceptable, or an incorrect substitution whose closeness is at least 0.5,
or a far substitution whose low_confidence flag is true.
far is an incorrect substitution whose closeness is below 0.5 and whose confidence is not low.
missing and extra match those scoring statuses.

Enum: `exact`, `acceptable_variant`, `close`, `far`, `missing`, `extra`

## HeardAs

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scheme` | string | yes | The request scheme. Koine is the product path. The letter table also covers attic-greek, classical-latin, ecclesiastical-latin, living-koine, and lucian. The worker scores koine-greek. |
| `script` | enum | yes |  |
| `method` | enum | yes | posterior when at least one candidate received a CTC forward score from the request posteriors. distance-softmax otherwise. Both paths order candidates with the same softmax. Published confidence is the Platt map, not that mass. |
| `temperature` | number | yes | Softmax temperature used to order candidates. The shipped value is 0.1. Published confidence is a separate Platt map of the top-1 gap, the normalized tutor cost, and a non-word indicator. |
| `other_score` | number | yes | Rank score of the other bin before the softmax. The shipped value is 0.5. |
| `truncated` | boolean | yes | True when the one-edit neighborhood was capped at 1500 lexicon rows. |
| `candidates` | array of `HeardAsCandidate` | yes | Whole-string ranking. For one word this is that word's list. For a verse it is the spelling of the whole clause; read `words` for a lexicon hit on each target word. |
| `words` | array of `HeardAsWord` | yes | One ranking per target word, sliced by the tutor alignment. |

## HeardAsWord

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `word` | string | yes |  |
| `lemma` | string | yes |  |
| `target_phones` | array of string | yes |  |
| `observed_phones` | array of string | yes | Heard phones aligned to this target word. |
| `truncated` | boolean | yes |  |
| `candidates` | array of `HeardAsCandidate` | yes |  |

## HeardAsCandidate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `rank` | integer | yes |  |
| `kind` | enum | yes |  |
| `is_word` | boolean | yes | True only for a lexicon row. |
| `word` | string or null |  | Lexicon orthography, or the generated spelling. Null for other. |
| `lemma` | string or null |  |  |
| `greek` | string or null |  | Greek spelling. The lexicon form for a word, the generated letters for a non-word. Null for Latin schemes and for other. |
| `latin` | string or null |  | ASCII transliteration of the candidate phones. Null for other. |
| `ipa` | string or null |  |  |
| `phones` | array or null |  |  |
| `similarity` | number or null |  | Guesser similarity of the candidate phones to the heard phones. A transliteration is one cheap edit below a perfect match (1 - 0.25/n - 0.01), not 1. Null for other. |
| `score` | number |  | Value that entered the softmax, before truncation of the confidence. |
| `ctc_log_prob` | number or null |  | CTC forward log-probability when method is posterior and this candidate was scored. Null otherwise. |
| `confidence` | number | yes | Platt confidence of the top row, truncated to 6 decimals. Later rows keep softmax proportions scaled to stay below it. The list sums to at most 1. |

## Problem

RFC 9457 problem details. `type` is a section of the errors guide.
`detail` is the same sentence earlier plain-text errors used.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | yes | Problem type. A section of /docs/errors. |
| `title` | string | yes | Short HTTP reason. |
| `status` | integer | yes | HTTP status code. |
| `detail` | string | yes | One sentence. No pronunciation advice. |
| `instance` | string |  | Request path. |
| `code` | string |  | Stable token on rate-limit, body-size, upload-slot, and audio_url failures. Examples include `rate-limit-exceeded`, `body-too-large`, `upload-slots-full`, `audio-url-host`. |

## UnscoredResponse

HTTP 200 when the trimmed audio is under 0.12 s. proximity is null. This is not a problem document.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `api` | `tutor-v1` | yes |  |
| `scheme` | string | yes |  |
| `target_text` | string | yes |  |
| `proximity` | null | yes |  |
| `unscored` | `too_short` | yes |  |
| `duration_s` | number | yes | Trimmed duration in seconds, 6 decimal places. |
| `input_quality` | object |  | Present on audio responses. Does not change proximity. Absent on IPA responses. |

## DifferentWord

Closest one-edit lexicon neighbor. `detected` does not change proximity.
`different_word: false` on the request returns checked false and skips the search.
A verse returns reason `single-word targets only`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `checked` | boolean | yes |  |
| `detected` | boolean | yes |  |
| `reason` | string |  | Present when a verse skips the check. |
| `word` | string |  |  |
| `lemma` | string |  |  |
| `similarity` | number |  |  |

## JsonRpcRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `jsonrpc` | `2.0` | yes |  |
| `id` | string / integer / null |  | String or number. Omit for a notification. |
| `method` | string | yes |  |
| `params` | object |  |  |

## JsonRpcResponse

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `jsonrpc` | `2.0` | yes |  |
| `id` | string / integer / null |  |  |
| `result` | object |  |  |
| `error` | object |  |  |

## Headers

### RateLimitPolicy

draft-ietf-httpapi-ratelimit-headers. A key is `"tutor";q=120;w=60`.
The Workers binding `TUTOR_KEY` is authoritative when present.
The in-memory window is the fallback. There is no anonymous quota.

### RateLimit

Remaining requests `r` and whole seconds `t` until the oldest counted request leaves the window.

### RetryAfter

Seconds to wait. Same value as RateLimit `t`, sent on 429.

# Changelog

## 2026-10-15

The public host serves guides next to the API. A later pass the same day aims the same host at agents. The public worker is IPA API. `GET /health` is `ipa-api zipa`. `POST /v1/tutor` and `api: tutor-v1` stay. The MCP registry name stays `com.raymati/ipa`.

### Audio as JSON

Chat, Claude, and Gemini pass JSON to tools. They do not attach a microphone. `proximity` is not retuned.

- `score`, `heard_as`, and `recognize` accept `ipa`, `audio_base64` (at most 256 KiB decoded), or `audio_url`. `ipa` wins when it is also sent.
- `create_upload` returns a PUT URL. This build stores the wav in process memory for 15 minutes (32 slots). It does not sign Cloudflare R2. When `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_BUCKET` are all set, the note says so and still returns the memory URL. The response does not echo those values.
- `ui://ipa/record.html` (`GET /apps/record`) is a record-button stub. The MIME type is `text/html;profile=mcp-app`. `score` and `score_attempt` point at it.
- HTML responses send `Content-Security-Policy`. `script-src` includes `'unsafe-inline'` so the landing JSON-LD block and the record widget can run.
- [Install](/docs/install.md), [Privacy](/privacy.md), and [Terms](/terms.md). Privacy and terms are placeholders for Aaron to review.
- `robots.txt` always allows the search and user-triggered agents. `ALLOW_TRAINING_CRAWLERS` in `scripts/build_api_docs.py` defaults to allow for `GPTBot`, `ClaudeBot`, `Google-Extended`, and `CCBot`.
- `/server.json` is the official MCP registry document. The name is `com.raymati/ipa`. It is not published. `/server-card.json` is the same JSON.
- Scoring requires an `ipa_live_` key. A missing key is 401 `api-key-required`. [Auth](/docs/auth.md) says how to request one. OAuth 2.1 is a design. This host does not serve `/.well-known/oauth-protected-resource`.

### Agent discovery

The score contract is unchanged. `proximity` is not retuned.

- `POST /mcp` lists `score`, `heard_as`, `recognize`, `create_upload`, and `score_attempt` (alias of `score`). `heard_as` is the `heard_as` object from the same score and does not change `proximity`.
- HTML GETs honor `Accept: text/markdown` and set `Vary: Accept`. `Prefer: return=consolidated` also gets `Preference-Applied`. That pair is draft-consolidated-content-01, not an RFC. `.md` URLs stay.
- `robots.txt` allows the documented AI crawler tokens, including the `Google-Extended` and `Applebot-Extended` control tokens.
- The landing page embeds schema.org `WebAPI` JSON-LD.
- `/.well-known/agent-card.json` is the A2A v1 discovery file. This host does not implement an A2A task endpoint.
- [Registries](/docs/registries.md) and [Submitting](/docs/submitting.md) have the submit steps. Nothing was submitted.

### Guides

- OpenAPI 3.1 at `/openapi.json`. Response examples are filled by the scorer from the requests in `openapi.yaml`.
- `/llms.txt`, `/llms-full.txt`, and a Markdown alternate for each HTML guide (`Link: rel=alternate; type=text/markdown` and `rel=describedby`).
- `/.well-known/security.txt` (RFC 9116). Convenience copies: `openapi.json`, `ai-plugin.json`, `ai-catalog.json`. The server card is `/server-card.json`.
- Errors are `application/problem+json`. `detail` keeps the previous plain-text sentence. Success bodies are unchanged.
- `RateLimit-Policy: "tutor";q=120;w=60` for an `ipa_live_` key, on `POST /v1/tutor` and `POST /mcp` `tools/call`. Binding `TUTOR_KEY` is the limit when it is bound. There is no anonymous quota. The in-memory window is the fallback. POST bodies are capped at 400 KiB. `audio_url` is https-only with a 256 KiB streamed cap. Proximity is not retuned.

Audio still reaches ZIPA through the container behind the Rust worker (route wiring `04354ee`, image prototype `a16c62c`). Proximity does not read GOP.

The lexicon and the Platt map remain the `d0b6159` set. This documentation pass does not retune proximity, stress, `different_word`, or `heard_as`.

# Registries

Submit these after `https://ipa.raymati.com` answers on the public internet. Do not submit from a build, a test, or this repository.

The score contract does not depend on any directory listing. Directory forms for Claude, ChatGPT, Smithery, Glama, PulseMCP, and mcp.so are in [Submitting](/docs/submitting.md). This page is the official MCP registry and APIs.guru.


## MCP Registry

The registry is a preview. The published schema can still break. Do not publish from this repository.

- Authentication: <https://modelcontextprotocol.io/registry/authentication>
- Quickstart: <https://modelcontextprotocol.io/registry/quickstart>
- Remote servers: <https://modelcontextprotocol.io/registry/remote-servers>
- Default registry: `https://registry.modelcontextprotocol.io`

The name is `com.raymati/ipa`. DNS domain verification for `raymati.com` grants `com.raymati.*/*`. A GitHub login would be the `io.github.*` namespace, which is the wrong namespace for this name. Do not invent a GitHub login to fill a placeholder.

`https://ipa.raymati.com/mcp` has to be reachable on the public internet before publish. The document this host already serves is [/server.json](/server.json):

```json
{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
  "name": "com.raymati/ipa",
  "description": "Score a Koine Greek attempt. Tools score, heard_as, recognize, and create_upload.",
  "version": "0.2.0",
  "remotes": [
    {"type": "streamable-http", "url": "https://ipa.raymati.com/mcp"}
  ]
}
```

`remotes[0].type` is `streamable-http`. There is no legacy SSE endpoint.

### DNS proof

The TXT record goes on the apex of `raymati.com`. A selector such as `_mcp-auth` or `_mcp-registry` is never read.

1. Ed25519 needs OpenSSL 3: `openssl genpkey -algorithm ED25519`.
2. Publish this TXT record on the apex (the public key is the one from that keypair; do not commit the private key):

```text
v=MCPv1; k=ed25519; p=<PUBLIC_KEY>
```

3. `mcp-publisher login dns --domain raymati.com --private-key HEX_KEY`
4. `mcp-publisher publish` with the path to `server.json`.

`--registry` is a flag on `login`, not on `publish`. Leave it unset to use `https://registry.modelcontextprotocol.io`.

HTTP alternative: host the same record at `https://raymati.com/.well-known/mcp-registry-auth` and run `mcp-publisher login http`.

On rotation, remove the stale `MCPv1` TXT records. Anthropic's connector directory does not use this DNS proof. That proof is only for the official MCP registry.


## APIs.guru

They aggregate an OpenAPI document. They do not host it. They fetch the URL again later, so the document has to stay at that address.

- Form: <https://apis.guru/add-api>
- Rules: <https://github.com/APIs-guru/openapi-directory/blob/main/CONTRIBUTING.md>

Submit `https://ipa.raymati.com/openapi.json` after deploy.

- The API is public, the URL is persistent, and the description is useful to someone who is not the author.
- Format: OpenAPI.
- Official definition: yes. This repository is the source of `openapi.yaml`.

## Optional llms.txt lists

<https://llmstxt.org/> is the convention. Community directories that scrape `/llms.txt` are informal. Listing the origin there is optional and is not part of the protocol. One public example is <https://llmstxt.site/>. Treat any such form as a courtesy copy, not a registry of record.

# Install

`POST /mcp` is MCP Streamable HTTP. `GET /mcp` is 405. There is no legacy SSE endpoint. `initialize` and `tools/list` need no key, so a client can discover the tools. `tools/call` requires an `ipa_live_` key (`Authorization: Bearer` or `X-API-Key`) and allows 120 calls per 60 seconds. Request a key from the operator at <https://ipa.raymati.com/docs>. The snippets below show where that header goes.

Tools, in list order: `score`, `heard_as`, `recognize`, `create_upload`, `score_attempt`. Every tool sends `title`, `inputSchema`, `outputSchema`, and `annotations.readOnlyHint`, `openWorldHint`, and `destructiveHint`.

Chat, Claude, and Gemini pass JSON. They do not attach microphone audio to a tool call. Send `ipa`, `audio_base64` (a WAV, at most 256 KiB decoded), or `audio_url`. `ipa` wins when it is also sent. A larger clip calls `create_upload`, PUTs the wav to `upload_url` with `Content-Type: audio/wav`, and passes `audio_url`. This build stores that wav in process memory for 15 minutes. It does not sign Cloudflare R2.

`score` and `score_attempt` point at the record widget `ui://ipa/record.html` ([/apps/record](/apps/record)). The widget MIME type is `text/html;profile=mcp-app`. Codex Desktop and ChatGPT on iOS deny `getUserMedia` even when `ui.permissions.microphone` is declared. ChatGPT on desktop Chrome can grant it.

## ChatGPT

[Creating custom MCP apps in ChatGPT](https://help.openai.com/en/articles/12584461-creating-custom-mcp-apps-in-chatgpt): Plugins, then the plus, then Add custom MCP server. URL `https://ipa.raymati.com/mcp`. Auth is an API key: `Authorization: Bearer ipa_live_...`. Create as a plugin.

App review is the OpenAI plugin submission, not a self-serve listing. Review checks the HTTP `Content-Security-Policy` and the widget `_meta.ui.csp` (`connectDomains` and `resourceDomains` are `https://ipa.raymati.com`).

## Claude

In the Claude app: Customize, Connectors, Add custom connector, URL `https://ipa.raymati.com/mcp`.

Directory submission is separate: <https://claude.ai/directory/manage>. Steps are in [Submitting](/docs/submitting.md).

## Claude Code

```bash
claude mcp add --transport http ipa https://ipa.raymati.com/mcp
```

A JSON entry has to include `"type": "http"`. A `url` without `type` is treated as stdio and skipped. `streamable-http` is an alias for `http`. Source: <https://code.claude.com/docs/en/mcp>.

## Cursor

`~/.cursor/mcp.json` or `.cursor/mcp.json`. Source: <https://cursor.com/docs/mcp>.

```json
{"mcpServers":{"ipa":{"url":"https://ipa.raymati.com/mcp","headers":{"Authorization":"Bearer ipa_live_..."}}}}
```

## VS Code

`.vscode/mcp.json` on VS Code 1.101 or newer.

```json
{"servers":{"ipa":{"type":"http","url":"https://ipa.raymati.com/mcp","headers":{"Authorization":"Bearer ipa_live_..."}}}}
```

## Gemini CLI

```bash
gemini mcp add --transport http ipa https://ipa.raymati.com/mcp
```

`settings.json` uses `httpUrl`, not `url`. Source: <https://geminicli.com/docs/tools/mcp-server/>.

## Content-Security-Policy

HTML responses, including this page and `/apps/record`, send:

```text
default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self' https://chatgpt.com https://chat.openai.com https://claude.ai
```

`'unsafe-inline'` is there so the landing `application/ld+json` block and the inline record widget can run. Widget review uses `_meta.ui.csp` as well as this header.

## robots.txt

`ALLOW_TRAINING_CRAWLERS` in `scripts/build_api_docs.py` is one flag. It defaults to `True`.

When it is true, `GPTBot`, `ClaudeBot`, `Google-Extended`, and `CCBot` get `Allow: /`. When it is false, those four get `Disallow: /`.

These stay `Allow: /` either way: `ChatGPT-User`, `OAI-SearchBot`, `Claude-User`, `Claude-SearchBot`, `PerplexityBot`, `Perplexity-User`, and `User-agent: *`. A named group wins over `*` for that bot.

`Google-Extended` and `Applebot-Extended` are control tokens, not HTTP user-agents. Bytespider is listed and is widely reported to ignore `robots.txt`.

# Auth

Scoring requires an `ipa_live_` key. That covers `POST /v1/tutor`, `POST /v1/bench`, `PUT` and `GET /v1/uploads/{id}`, and `POST /mcp` when the method is `tools/call` (`score`, `heard_as`, `recognize`, `create_upload`, `score_attempt`). Send it as `Authorization: Bearer ipa_live_...` or `X-API-Key`. The window is `"tutor";q=120;w=60` on binding `TUTOR_KEY` (`namespace_id` `471702`), counted per key. The in-memory window is the fallback when the binding is absent.

A missing key is 401 `api-key-required` with `WWW-Authenticate: Bearer realm="https://ipa.raymati.com"`. An unrecognized or malformed key is 401 `api-key-rejected`. There is no anonymous scoring tier.

These stay public, with no key: the guides, `/openapi.json`, `/llms.txt`, `/llms-full.txt`, `/.well-known/*`, `/robots.txt`, `/sitemap.xml`, `/health`, the logo assets, and MCP `initialize` plus `tools/list`.

Request a key from the operator. The contact is <https://ipa.raymati.com/docs>. The operator runs `scripts/mint_key --label beta-1`, which prints a secret once and stores `sha256:` plus the hex digest. The record keeps the label, a short prefix, `created_at`, `audio_count`, and `ipa_count`. Audio is `audio_base64`, `audio_url`, an upload PUT, or `create_upload`. An `ipa` field counts as IPA, including when audio is also sent. No route lists secrets.

This host does not serve `/.well-known/oauth-protected-resource`. OAuth is not implemented. The API key is the credential.

## Later: MCP OAuth 2.1

Design only. This build does not implement an authorization server, a token endpoint, or Dynamic Client Registration.

The target is the [MCP authorization spec, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), which sits on OAuth 2.1. Both are drafts relative to a finished RFC for the MCP profile.

Client registration prefers [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) over Dynamic Client Registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591.html)). The authorization server should advertise `client_id_metadata_document_supported`. DCR stays a deprecated fallback. Notes: [MCP blog, client registration](https://blog.modelcontextprotocol.io/posts/client_registration/).

Refresh tokens, from [SEP-2207](https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance):

- Clients should include `refresh_token` in `grant_types`.
- A client may request the `offline_access` scope only when the authorization server lists it in `scopes_supported`.
- The resource server should not advertise `offline_access`.
- Public clients must rotate refresh tokens (OAuth 2.1 section 4.3.1).

When that server exists, an OAuth access token can stand in for the API key. Until then, scoring still requires `ipa_live_`.

# Submitting

Do not submit from a build, a test, or this repository. Run these by hand after `https://ipa.raymati.com` answers on the public internet.


## MCP Registry

The registry is a preview. The published schema can still break. Do not publish from this repository.

- Authentication: <https://modelcontextprotocol.io/registry/authentication>
- Quickstart: <https://modelcontextprotocol.io/registry/quickstart>
- Remote servers: <https://modelcontextprotocol.io/registry/remote-servers>
- Default registry: `https://registry.modelcontextprotocol.io`

The name is `com.raymati/ipa`. DNS domain verification for `raymati.com` grants `com.raymati.*/*`. A GitHub login would be the `io.github.*` namespace, which is the wrong namespace for this name. Do not invent a GitHub login to fill a placeholder.

`https://ipa.raymati.com/mcp` has to be reachable on the public internet before publish. The document this host already serves is [/server.json](/server.json):

```json
{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
  "name": "com.raymati/ipa",
  "description": "Score a Koine Greek attempt. Tools score, heard_as, recognize, and create_upload.",
  "version": "0.2.0",
  "remotes": [
    {"type": "streamable-http", "url": "https://ipa.raymati.com/mcp"}
  ]
}
```

`remotes[0].type` is `streamable-http`. There is no legacy SSE endpoint.

### DNS proof

The TXT record goes on the apex of `raymati.com`. A selector such as `_mcp-auth` or `_mcp-registry` is never read.

1. Ed25519 needs OpenSSL 3: `openssl genpkey -algorithm ED25519`.
2. Publish this TXT record on the apex (the public key is the one from that keypair; do not commit the private key):

```text
v=MCPv1; k=ed25519; p=<PUBLIC_KEY>
```

3. `mcp-publisher login dns --domain raymati.com --private-key HEX_KEY`
4. `mcp-publisher publish` with the path to `server.json`.

`--registry` is a flag on `login`, not on `publish`. Leave it unset to use `https://registry.modelcontextprotocol.io`.

HTTP alternative: host the same record at `https://raymati.com/.well-known/mcp-registry-auth` and run `mcp-publisher login http`.

On rotation, remove the stale `MCPv1` TXT records. Anthropic's connector directory does not use this DNS proof. That proof is only for the official MCP registry.


## Claude directory

- Guide: <https://claude.com/docs/connectors/building/submission>
- Portal: <https://claude.ai/directory/manage> (a paid plan; a Team or Enterprise owner)

The connector is a remote MCP server. Tools need titles and read-only or destructive hints, plus docs, a privacy page, and an icon. Anthropic's directory does not require the MCP registry DNS proof. That proof is only for `com.raymati/ipa` on the official registry.

## ChatGPT

Custom MCP is Plugins, plus, Add custom MCP server, URL `https://ipa.raymati.com/mcp`, auth none. Source: <https://help.openai.com/en/articles/12584461-creating-custom-mcp-apps-in-chatgpt>.

App review is the OpenAI plugin submission, not a self-serve listing. Review checks the HTTP `Content-Security-Policy` and the widget `_meta.ui.csp`.

## Smithery

<https://smithery.ai/new> takes the public HTTPS URL, or:

```bash
smithery mcp publish "https://ipa.raymati.com/mcp" -n @org/ipa
```

If a scan fails, an optional static card can live at `/.well-known/mcp/server-card.json`. This host does not publish that extra card. Docs: <https://smithery.ai/docs/build/publish>.

## Glama

On the connectors page: Add MCP Server, then Connector, then the HTTPS Streamable HTTP URL. Claim the listing with a TXT record at `_glama-claim.` or with `/.well-known/glama.json`. FAQ: <https://glama.ai/mcp/faq>.

## PulseMCP

The primary path is the GitHub topic `mcp-server`, which they crawl weekly, not only a form. <https://www.pulsemcp.com>.

## mcp.so

The listing form is on <https://mcp.so>.

## APIs.guru

They aggregate. They do not host. After deploy, submit `https://ipa.raymati.com/openapi.json` at <https://apis.guru/add-api>. Rules: <https://github.com/APIs-guru/openapi-directory/blob/main/CONTRIBUTING.md>.

# Privacy

**PLACEHOLDER FOR AARON TO REVIEW.** This page is not a legal policy. Do not publish it as final.

IPA API scores a Greek target. A key record stores a SHA-256 hash, a label, a short prefix, and audio versus IPA counters. It does not store the secret. `create_upload` keeps a WAV in process memory for at most 15 minutes and at most 32 slots, then drops it. The process does not write that audio to a database, a training set, or Cloudflare R2 in this build.

Request logs, if a host adds them later, are outside this placeholder. Aaron replaces this page before any public submission that asks for a privacy policy.

# Terms

**PLACEHOLDER FOR AARON TO REVIEW.** This page is not a legal policy. Do not publish it as final.

Scoring requires an API key, with a per-key rate limit. The code of this repository is MIT. The New Testament lexicon and SBLGNT text are CC BY or CC BY-SA, as named in the lexicon licenses. Those statements are descriptions of the repository, not a substitute for terms Aaron writes.

Aaron replaces this page before any public submission that asks for terms of service.
