View Markdown
# Mobile integration guide (iOS / Android)

For the iOS and Android developers integrating voice expense entry with the Laxmi server.
NLU model output schema version: **1**. Endpoint details: [API reference](/guide/api) · playground: `/docs`.

[TOC]

## 0. Checklist

| # | Task | Section |
|---|---|---|
| 1 | Send the app key `X-API-Key` and a device id `X-Device-Id` on every call | [2](#2-server-basics) |
| 2 | Add ONNX Runtime **and** onnxruntime-extensions to the app | [4](#4-add-onnx-runtime-to-the-app) |
| 3 | Download / update the models (NLU file + speech packs), check sha256, keep the previous one | [3](#3-download-and-update-the-models) |
| 4 | Run the NLU model offline: 2 string inputs → 1 JSON string | [5](#5-run-the-nlu-model-on-the-device) |
| 5 | (Speech) run the ASR pack offline | [6](#6-speech-model-on-the-device-asr-packs) |
| 6 | Language picker + "Request this language" | [7](#7-language-picker-and-requests) |
| 7 | Online: `POST /v2/voice/parse` (or `/v2/nlu/parse` for typed text), fall back to the device models | [8](#8-online-flow) |
| 8 | Confirm screen → `POST /v2/nlu/results` (with `event_id`) | [9](#9-confirm-screen-and-saved-results) |
| 9 | Offline queue → `POST /v2/nlu/results` (without `event_id`) | [10](#10-offline-queue) |
| 10 | Pass all test vectors on real phones; report latency | [11](#11-test-vectors-acceptance) |
| 11 | Settings → "Delete my data" → `DELETE /v2/user-data` | [13](#13-delete-my-data) |

**The app contains no expense-extraction logic.** No number parsing, no date rules, no category keywords. The model
file does all of it. The app downloads it, runs it, shows the result and reports what the user saved.

## 1. Flow

```
User speaks ─► online? ── yes ─► POST /v2/voice/parse  (audio + language, timeout 15 s)
                  │                 ├─ 200 → show result (keep event_id)
                  │                 ├─ 422 → show asr.transcript, manual entry
                  │                 └─ timeout / network error / 5xx ─┐
                  │                                                   ▼
                  └──── no ─────────────► device: ASR pack → transcript → laxmi_nlu.onnx
                                                                   │
                             Confirm screen (user confirms or edits) ◄┘
                                      │ save expense = what the user saw after editing
                                      ▼
                     results queue ──► POST /v2/nlu/results (when online)
                       parsed by the server: entry with event_id
                       parsed on the device: entry with transcript + model output
```

Typed text instead of voice: `POST /v2/nlu/parse` (timeout 4 s); the rest is the same.

## 2. Server basics

| Item | Value |
|---|---|
| Base URL | provided by the backend (LAN in development, e.g. `http://192.168.1.23:8082`) |
| Device id | a random UUID created once per app install and stored locally. **Not** a hardware / advertising id. Send it as the `X-Device-Id` header on every request (required on parse, results, language requests and delete) |
| App key | header **`X-API-Key: <key>`** on **every** request, including model downloads. The backend gives you the key; it is not in this guide or in git. Missing / wrong → `401 missing_api_key` / `invalid_api_key` (see [2.1](#21-the-app-key)) |
| Content type | JSON (`application/json`), except audio uploads (`multipart/form-data`) |
| Dates | `reference_date` = the user's **local** date `yyyy-MM-dd`; datetimes ISO 8601 with offset (`2026-09-15T08:00:00+05:30`) |
| Number / date formatting | always format with `en_US_POSIX` (iOS) / `Locale.US` (Android), so digits are ASCII |
| Errors | `{"code": "...", "message": "..."}` with the HTTP status (section [12](#12-errors)); switch on `code`, never on `message` |
| Timeouts | `/v2/nlu/parse` 4 s · `/v2/voice/parse` 15 s · downloads: none, resume with `Range` |

### 2.1 The app key

Every request carries two headers:

```http
X-API-Key: <app key from the backend team>
X-API-Key: <app key>
X-Device-Id: 6f1c…
```

- **Where to keep it:** in the build configuration, e.g. an `.xcconfig` → `Info.plist` value on iOS, or a
  `buildConfigField` from `local.properties` / CI secrets on Android. Keep it out of the source repository.
- **Never log it**, and never show it in the UI or in crash reports.
- **Add it in one place:** one HTTP client / interceptor for all calls (`URLSession` configuration or an OkHttp
  interceptor), so model downloads get it too.
- **`401 invalid_api_key` after a release:** the backend rotated the key. The server can accept two keys during a
  change-over (old + new). Ship the new key before the old one is removed.
- **What it does and doesn't protect against:** it stops other apps, scripts and people who found the URL from
  using the API. A key inside an app can still be extracted by someone who takes the app apart, so the server also
  checks the device id, and more checks can be added (see the setup guide).

## 3. Download and update the models

### 3.1 Check for new versions

Every time the app starts while online, send what the device has (leave a model out if the device doesn't have it)
and the highest NLU output `schema_version` this app build understands (today: `1`):

```http
GET /v2/models?installed=nlu@1.1.0,asr-gu@20260911.115542&max_nlu_schema=1
```

```json
{"models": [
  {"id": "asr-gu", "type": "asr", "languages": ["gu"], "version": "20260911.115542", "precision": "fp16",
   "size_bytes": 261122803,
   "files": [{"name": "encoder.onnx", "url": "http://…/v2/models/asr-gu/files/encoder.onnx",
              "size_bytes": 251271775, "sha256": "abe1837e…"}, "…"],
   "installed_version": "20260911.115542", "update_available": false},
  {"id": "nlu", "type": "nlu", "languages": ["gu", "hi", "en"], "version": "1.2.0", "schema_version": 1,
   "review_threshold": 0.8, "size_bytes": 58131424,
   "files": [{"name": "laxmi_nlu.onnx", "url": "http://…/v2/models/nlu/files/laxmi_nlu.onnx?version=1.2.0",
              "size_bytes": 58034536, "sha256": "25fc717f…"},
             {"name": "manifest.json", "…": "…"}, {"name": "vectors.jsonl", "…": "…"}],
   "installed_version": "1.1.0", "update_available": true}
]}
```

- **`nlu`:** the version the server runs and that the app should use. A version the backend has published but
  not released yet is never offered. A version with a `schema_version` above `max_nlu_schema` is skipped, so an old
  app build never gets a file it cannot read.
- **`asr-<lang>`:** one speech pack per language. Download only the languages the user picked (~260 MB each).
- **A model missing from `models`:** nothing is released for it. Keep what you have (or use server-only mode).

### 3.2 Decide

| Response | Action |
|---|---|
| model missing | nothing to do; keep the current file |
| `update_available: false` | nothing to do |
| `update_available: true` | download that model's `files` (3.3); keep using the old one until the new one passes the checks |

`update_available` is also `true` when the offered version is **older** than the device's: the backend rolled back.
Switch to it the same way (use the kept previous file if its version matches, otherwise download).

### 3.3 Download

```http
GET /v2/models/{id}/files/{name}        (HEAD and Range supported → resumable; send X-API-Key)
```

1. Download on Wi-Fi, or on mobile data after user consent. The NLU file is ~60 MB; a speech pack ~260 MB.
2. Write to a temp file, then compute SHA-256 and compare it with `sha256`. Mismatch → delete and retry later.
   (`manifest.json` / `vectors.jsonl` carry no hash: check `size_bytes`.)
3. NLU: create a session with the new file and run 3 vectors from `vectors.jsonl` (section 11) as a self-test.
4. Pass → atomically move it into place, store `version`, and **keep the previous file** until the new one has run
   fine for a few days. Fail → delete the new file and keep the old one.

Storage: the app support directory, excluded from iCloud / Auto Backup (it can be downloaded again).

## 4. Add ONNX Runtime to the app

The NLU file uses standard ONNX Runtime operators (including `BeamSearch` from the `com.microsoft` domain) and
string / tokenizer operators from **onnxruntime-extensions**. Both libraries are required: without the extensions
the session fails to load with an "unknown op" error. The speech packs need ONNX Runtime only.

### 4.1 iOS (CocoaPods)

```ruby
pod 'onnxruntime-c'
pod 'onnxruntime-objc'              # Objective-C / Swift API
pod 'onnxruntime-extensions-c'
```

```swift
import onnxruntime_objc

final class NluModel {
    private let env: ORTEnv
    private let session: ORTSession

    init(modelPath: String) throws {
        env = try ORTEnv(loggingLevel: .warning)
        let options = try ORTSessionOptions()
        try options.setIntraOpNumThreads(2)
        // Registers the onnxruntime-extensions operators (C function exported by onnxruntime-extensions-c).
        try options.registerCustomOps(usingFunction: "RegisterCustomOps")
        session = try ORTSession(env: env, modelPath: modelPath, sessionOptions: options)
    }

    /// Returns the model's JSON string. Call off the main thread.
    func run(transcript: String, referenceDate: String) throws -> String {
        let inputs: [String: ORTValue] = [
            "transcript": try ORTValue(tensorStringData: [transcript], shape: [1]),
            "reference_date": try ORTValue(tensorStringData: [referenceDate], shape: [1]),
        ]
        let outputs = try session.run(withInputs: inputs, outputNames: ["json"], runOptions: nil)
        return try outputs["json"]?.tensorStringData().first ?? ""
    }
}
```

The method names above follow the ONNX Runtime Objective-C API (`registerCustomOpsUsingFunction:`,
`initWithTensorStringData:shape:`, `tensorStringDataWithError:`). Confirm them against the pod version you install.
Newer versions also offer `registerCustomOpsUsingFunctionPointer:`.

### 4.2 Android (Gradle)

```kotlin
dependencies {
    implementation("com.microsoft.onnxruntime:onnxruntime-android:<version>")
    implementation("com.microsoft.onnxruntime:onnxruntime-extensions-android:<version>")
}
```

```kotlin
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import ai.onnxruntime.extensions.OrtxPackage

class NluModel(modelPath: String) : AutoCloseable {
    private val env = OrtEnvironment.getEnvironment()
    private val session: OrtSession = env.createSession(modelPath, OrtSession.SessionOptions().apply {
        setIntraOpNumThreads(2)
        registerCustomOpLibrary(OrtxPackage.getLibraryPath())   // onnxruntime-extensions
    })

    /** Returns the model's JSON string. Call off the main thread. */
    fun run(transcript: String, referenceDate: String): String =
        OnnxTensor.createTensor(env, arrayOf(transcript), longArrayOf(1)).use { t ->
            OnnxTensor.createTensor(env, arrayOf(referenceDate), longArrayOf(1)).use { d ->
                session.run(mapOf("transcript" to t, "reference_date" to d)).use { result ->
                    @Suppress("UNCHECKED_CAST")
                    (result.get(0).value as Array<String>)[0]
                }
            }
        }

    override fun close() = session.close()
}
```

### 4.3 Runtime notes

- Create the session **once** (after download / app start) and reuse it. It is thread-safe for `run`.
- The first run after loading is slower. On a desktop CPU a run takes ~90 ms; phone numbers are part of the
  acceptance test (section 11).
- Keep the ONNX Runtime and extensions versions in sync (same release train).

## 5. Run the NLU model on the device

### 5.1 Inputs

| Name | Type | Value |
|---|---|---|
| `transcript` | string tensor, shape `[1]` | the ASR text **as is** (native script, no romanizing, no cleanup) |
| `reference_date` | string tensor, shape `[1]` | the user's local today, `yyyy-MM-dd` (e.g. `2026-09-15`) |

### 5.2 Output

`json` (string tensor, shape `[1]`). The contract is `contracts/nlu-model-output.schema.json`.

```json
{"amount":400,"category":"Groceries","merchant":null,"note":"કેળા","language":"gu",
 "spent_at":"2026-09-12","confidence":0.94,"needs_review":false}
```

| Field | Type | Meaning |
|---|---|---|
| `amount` | number | rupees (major units); store as integer paise = `round(amount * 100)`. **`0` = no amount was said** |
| `category` | string | one of the 15 categories (the same list as the API); `Misc` when no item or merchant was said |
| `merchant` | string or null | canonical name, e.g. `Swiggy` |
| `note` | string or null | the item in the user's words / script |
| `spent_at` | `yyyy-MM-dd` | already resolved ("3 days ago" → a date) |
| `language` | string | `gu` / `hi` / `en` (a plain string; more may come) |
| `confidence` | 0-1 | calibrated |
| `needs_review` | bool | `confidence < review_threshold`: highlight the fields on the confirm screen |

The app adds `transcript` and `currency` itself.

### 5.3 What the app does with the output (the only rules on the device)

| Situation | App behaviour |
|---|---|
| `run` throws, the string is not valid JSON, or a required field is missing | open the confirm screen **empty** with the transcript shown (manual entry) |
| `confidence < 0.30` | treat it as not understood: manual entry (the server uses the same cut-off) |
| `amount == 0` | no amount was said: prefill the other fields, leave the amount empty and ask for it |
| `needs_review == true` | prefill, highlight all fields, do not auto-save |
| otherwise | prefill the confirm screen |

Don't "fix" the output with your own parsing. If something is systematically wrong, report it with the transcript;
the backend fixes it in the next model version.

## 6. Speech model on the device (ASR packs)

One FP16 ONNX pack per language (`asr-gu`, `asr-hi`): the same files the server runs. Each pack contains
`preprocessor.onnx`, `encoder.onnx`, `decoder.onnx`, `joiner.onnx`, `tokens.txt` and `manifest.json` (~261 MB). It
needs ONNX Runtime only (no extensions).

Decoding is identical to the server's reference implementation, `OnnxPack` in `server/app/engines/asr/onnx_pack.py`:

1. 16 kHz mono float32 samples → `preprocessor.onnx` → `features`, `features_length`
2. `encoder.onnx` → `encoder_out` `[1, 512, T]`
3. Greedy search: run `decoder.onnx` with token 256 (blank) and zero states. For each frame `t`, up to 10 times:
   `k = argmax(joiner(frame_t [1,512,1], decoder_out))`. If `k == 256`, go to the next frame; else emit `k` and
   re-run the decoder with `k` and its new states.
4. Text = the `tokens.txt` pieces joined, with `▁` → space

Don't use sherpa-onnx's own feature extraction with these packs: it drops words, including the leading amount.
Pass the resulting text **unchanged** as `transcript` to the NLU model.

Languages without a pack (e.g. Marathi): the server can still transcribe them (`server_asr: true` in
`GET /v2/languages`), so use `POST /v2/voice/parse` online only.

## 7. Language picker and requests

```http
GET /v2/languages          (X-API-Key + X-Device-Id)
```

| State | Show |
|---|---|
| `supported: true` | selectable |
| `supported: false`, `requested_by_device: false` | a **"Request this language"** button |
| `requested_by_device: true` | "Requested ✓" |

When the user taps **Request**:

```http
POST /v2/languages/requests
{"language": "mr", "platform": "android", "app_version": "1.4.0"}
```

`total_devices` in the response lets the app say "1,204 people asked for Marathi". Asking again is harmless: each
device counts once.

## 8. Online flow

Online, ask the server first. It uses the same model, cross-checks it, can fall back to Gemini, and every call
becomes training data.

**Voice:**

```http
POST /v2/voice/parse          multipart: audio, language, reference_date, currency, consent_store_audio
X-API-Key: <app key>
X-Device-Id: 6f1c…
```

**Typed text:**

```http
POST /v2/nlu/parse
X-API-Key: <app key>
X-Device-Id: 6f1c…
{"transcript": "ત્રણ દિવસ પહેલા ચારસો ના કેળા લીધા", "reference_date": "2026-09-15", "currency": "INR",
 "language": "gu"}
```

```json
{"amount": 400, "category": "Groceries", "merchant": null, "note": "કેળા", "spent_at": "2026-09-12",
 "language": "gu", "event_id": "484b9ca7…", "transcript": "ત્રણ દિવસ પહેલા ચારસો ના કેળા લીધા",
 "currency": "INR", "confidence": 1.0, "needs_review": false, "source": "model",
 "model_version": "1.2.0", "fallback_reason": null}
```

`/v2/voice/parse` returns the same fields plus an `asr` block (`transcript`, `engine`, `model_version`, …).

| Rule | Detail |
|---|---|
| Timeout | 4 s for text, 15 s for voice. On timeout / no network / 5xx → run the device models and use the **offline** path (section 10) |
| `422 no_expense_found` / `no_speech` | manual entry; for voice, show `asr.transcript` (the server stored the attempt) |
| `amount == 0` with `needs_review: true` | the user didn't say an amount (or the server couldn't confirm one): ask for it and keep the other fields |
| Keep `event_id` | needed when saving (section 9) |
| `source` | `model` / `gemini` / `rules`: informational |
| `language` (text) | send it when known (e.g. from the picker). Marathi and Hindi share a script |

## 9. Confirm screen and saved results

After the user taps Save on an expense that came from the **server** (it has an `event_id`), add an entry to the
results queue (section 10) and send it:

```http
POST /v2/nlu/results
X-API-Key: <app key>
X-Device-Id: 6f1c…
{"entries": [{
  "id": "3f2a9c1e-7b1d-4c55-9a51-0c2f9d2b8e11",
  "event_id": "484b9ca7…",
  "final": {"amount": 450, "category": "Groceries", "merchant": null, "note": "કેળા",
            "spent_at": "2026-09-12", "language": "gu"},
  "edited_fields": ["amount"],
  "transcript_edited": null,
  "confirm_time_ms": 2100,
  "edit_reason": "model_error"
}]}
```

| Field | How to fill it |
|---|---|
| `id` | a UUID v4 created when the user saves; it never changes (retries are safe) |
| `event_id` | from the parse response |
| `final` | exactly what is saved (after edits) |
| `edited_fields` | fields whose saved value differs from what was shown: `amount`, `category`, `merchant`, `note`, `spent_at` |
| `transcript_edited` | the corrected text if the user edited the transcript, else `null` |
| `confirm_time_ms` | time from showing the confirm screen to Save |
| `edit_reason` | optional one-tap chip, shown only when something was edited: 🤖 "App understood wrong" → `model_error`, 🗣️ "I changed it" → `user_changed`, 🎤 "Voice heard wrong" → `asr_error`; `null` if not tapped |

- `rejected` with `event_not_found` → drop it.
- The user's saved expense is never changed by the server.

## 10. Offline queue

When the **device models** parsed the expense (offline, or after a server timeout), save a queue entry when the
user saves. It has no `event_id`, but carries the transcript and the model output:

```json
{
  "id": "3f2a9c1e-7b1d-4c55-9a51-0c2f9d2b8e11",
  "transcript": "કાલે 300 ની ચા",
  "reference_date": "2026-09-15",
  "created_at": "2026-09-15T08:00:00+05:30",
  "model_json": {"amount":300,"category":"Food & Drink","merchant":null,"note":"ચા","language":"gu",
                 "spent_at":"2026-09-14","confidence":0.97,"needs_review":false},
  "model_version": "1.2.0",
  "model_confidence": 0.97,
  "final": {"amount": 300, "category": "Food & Drink", "merchant": null, "note": "ચા",
            "spent_at": "2026-09-14", "language": "gu"},
  "edited_fields": [],
  "transcript_edited": null,
  "confirm_time_ms": 1800,
  "edit_reason": null,
  "asr_model_version": "20260911.115542"
}
```

- `id`: a UUID v4 created when the entry is saved; it never changes (retries are safe).
- `transcript`, `reference_date`, `created_at`: **required** for offline entries.
- `model_json`: the parsed model output as returned (or `null` if the model could not run).
- `final`, `edited_fields`, `transcript_edited`, `confirm_time_ms`, `edit_reason` mean the same as in section 9.

Online and offline entries go through the same queue and the same call. When online, upload in batches of up to 200:

```http
POST /v2/nlu/results
{"entries": [ … ]}
```

```json
{"saved": ["3f2a9c1e-…"], "duplicates": [], "rejected": [{"id": "…", "reason": "invalid_date"}]}
```

| Result | Action |
|---|---|
| id in `saved` or `duplicates` | delete it from the queue |
| id in `rejected` | log it and delete it (it will never be accepted) |
| network error / 5xx | keep everything; retry with backoff (1 min, 5 min, 30 min, then hourly). Online entries (with `event_id`) may be dropped after 7 days; keep offline entries until they are accepted |
| 422 `invalid_request` | a bug in the app payload: log it; don't retry the same batch forever |

This call never uses Gemini and never changes saved expenses.

**Consent:** only send results when the user allowed their entries to be used to improve the app (a settings
toggle). Parsing (`/v2/voice/parse`, `/v2/nlu/parse`) is still allowed without it. Audio is kept only with
`consent_store_audio=true`.

## 11. Test vectors (acceptance)

Every published NLU model has vectors: sentences with the **exact** output that model file gives on the server.

```http
GET /v2/models/nlu/files/vectors.jsonl?version=1.2.0        (JSON lines, one object per line)
```

```json
{"transcript": "ત્રણ દિવસ પહેલા ચારસો ના કેળા લીધા", "reference_date": "2026-09-15",
 "expected_text": "{\"amount\":400,\"category\":\"Groceries\",…,\"confidence\":1.00,\"needs_review\":false}",
 "expected": {"amount": 400, "category": "Groceries", "merchant": null, "note": "કેળા", "language": "gu",
              "spent_at": "2026-09-12", "confidence": 1.0, "needs_review": false}}
```

The vectors describe **this file's behaviour, including its mistakes** (for example `paid 1,499 for shoes` may
expect `1399`, and `hello` expects a made-up expense with confidence 0.13). Compare against the expected output,
not against what the sentence "should" mean.

Write a debug / instrumentation test that runs the downloaded model on every vector:

| Check | Pass condition |
|---|---|
| `amount` | equal within 0.01 |
| `category`, `merchant`, `note`, `language`, `spent_at` | exactly equal |
| `confidence` | within ±0.02 (confidence is sensitive to tiny float differences; if only this check fails, report it, as the tolerance may be widened) |
| `needs_review` | equal (ignore it when `expected.confidence` is within ±0.02 of `review_threshold`) |

**Acceptance for integration:** 100% of vectors pass on:
- one recent iPhone and one older iPhone
- one mid-range and one low-end Android phone

Report back to the backend for each device:
- device model and OS version
- ONNX Runtime + extensions versions
- model load time, and run time p50 / p95 over all vectors
- peak memory

Targets: ≤ 500 ms (mid-range), ≤ 1.5 s (low-end). For mismatches, send the vector, the device output and the device
details.

## 12. Errors

| Endpoint | Status | `code` | App action |
|---|---|---|---|
| any app endpoint | 401 | `missing_api_key` / `invalid_api_key` | bug, or the key was rotated: update the app key (2.1). Don't retry the same request |
| any write call | 400 | `missing_device_id` / `invalid_device_id` | bug: fix the header |
| `POST /v2/nlu/parse`, `/v2/voice/parse` | 422 | `no_expense_found` (+ `event_id`, `asr` for voice) | manual entry |
| `POST /v2/voice/parse` | 422 | `no_speech` (+ `asr`) | ask the user to speak again |
| any | 422 | `invalid_request` (+ `details`) | fix the request (empty transcript, bad currency, …) |
| `POST /v2/voice/parse`, `/v2/asr/transcribe` | 400 | `unknown_language`, `unsupported_language`, `empty_audio`, `bad_audio` | fix the request / offer another language |
| `POST /v2/voice/parse`, `/v2/asr/transcribe` | 413 | `audio_too_large` | record shorter clips (≤ 10 MB) |
| `POST /v2/voice/parse`, `/v2/asr/transcribe` | 503 | `model_unavailable` | speech model missing on the server: use the device model |
| `POST /v2/nlu/results` | 200 | per entry `rejected[].reason` | delete the rejected entries |
| `GET /v2/models` | 200 | model missing from `models` | keep the current file / server-only |
| `GET /v2/models/{id}/files/{name}` | 404 | `model_file_not_found` | retry later via `GET /v2/models` |
| any | 5xx / timeout | — | offline path |

## 13. Delete my data

Settings → "Delete my data":

```http
DELETE /v2/user-data          (X-API-Key + X-Device-Id)
```

This deletes the device's stored audio, parse history, training labels and language requests on the server. The
expenses saved on the phone are not touched.

## 14. Versions and compatibility

| Change on the server | App impact |
|---|---|
| New NLU model version (retrained, new language) | none: download the new file (section 3) |
| NLU output fields change | `schema_version` increases → old app builds keep their file until updated (`max_nlu_schema`) |
| New speech pack version | none: `update_available` for `asr-<lang>` |
| New endpoints / fields | additive; existing fields keep their meaning |
| Old server endpoints (`/v1/*`, `/v2/nlu/feedback`, `/v2/nlu/sync`, `/v2/nlu/model*`) | not on this server: see [changes](/guide/changes) |

## 15. Links

| What | Where |
|---|---|
| Swagger UI / ReDoc | `http://<server>/docs` · `http://<server>/redoc` |
| This guide | `http://<server>/guide/mobile` (Markdown: `/guide/mobile.md`) |
| API reference | `http://<server>/guide/api` |
| OpenAPI | `http://<server>/openapi.json` |
| NLU model output schema | repo `contracts/nlu-model-output.schema.json` |
| How the model is trained | `http://<server>/guide/training` |
| ONNX Runtime extensions | <https://onnxruntime.ai/docs/extensions/> |