> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mathfi.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a CLI or agent

> A complete, self-contained contract for the MathFi.ai API, written so a coding agent can build a working client from this page alone. Credentials are the only input.

This page is the full contract in one place. A coding agent should be able to read it and produce a
working client without opening anything else.

<Note>
  **If you are an agent:** read this page top to bottom before writing code. Everything you need is
  here. The only values the user supplies are `MATHFI_BASE_URL`, `MATHFI_EMAIL` and
  `MATHFI_PASSWORD`. Take all three as configuration. Do not hard-code them, do not write the
  credentials to disk, do not log them.
</Note>

Machine-readable sources:

| Source                      | URL                                                              |
| --------------------------- | ---------------------------------------------------------------- |
| OpenAPI 3.0 spec            | `https://docs.mathfi.ai/api-reference/openapi.yaml`              |
| This site as one text file  | `https://docs.mathfi.ai/llms-full.txt`                           |
| Page index for retrieval    | `https://docs.mathfi.ai/llms.txt`                                |
| Any single page as Markdown | append `.md`, e.g. `https://docs.mathfi.ai/developers/agents.md` |

## The contract in brief

```yaml theme={null}
base_url:
  pattern: https://{tenant}-api.mathfi.ai   # per-customer; NEVER hard-code a host
  source: MATHFI_BASE_URL                   # take it as configuration
auth:
  endpoint: POST /api/login
  body: { username: <email>, password: <password> }
  response_field: access_token
  header: "Authorization: Bearer <access_token>"
  lifetime_seconds: 3600        # sliding; 86400 absolute from login
  api_keys: none
credentials:                    # the only inputs a client needs
  MATHFI_BASE_URL: string       # your tenant's API root
  MATHFI_EMAIL: string
  MATHFI_PASSWORD: string
conventions:
  keys: uuid, opaque, pass back verbatim
  timestamps: ISO-8601 UTC
  list_params: offset and limit are REQUIRED on every list endpoint
  multipart_part_name: _file    # underscore is required
  null_metric: means undefined, not zero
limits:
  upload_bytes_max: 534773760
  signed_url_ttl_seconds: 3600
  refinery_max_columns: 70
  refinery_buckets_allowed: [4, 10, 20]
  concurrent_refinements_per_user: 5
  concurrent_training_jobs_per_user: 5
  models_kept_per_run: 3
  scaling_factor_range: [8, 499]
  scaling_factor_default: 19
```

## Products and the pipeline

Two products, run in sequence or independently.

```
Feature Refinery                        Model Crucible
────────────────                        ──────────────
create ──▶ upload TRAIN+TEST ──▶ start   create dataset ──▶ [upload+clean | nothing]
       ──▶ poll ──▶ result                              ──▶ poll dataset
                    │                                   ──▶ create training job
                    └── featureRefinementKey ──────────▶ ──▶ poll job
                                                        ──▶ read candidates
                                                        ──▶ (optional) set champion
                                                        ──▶ create prediction ──▶ poll ──▶ download
```

The handoff is one field: pass `featureRefinementKey` when creating the dataset and no files move.

## Endpoints

Everything below is relative to your tenant's API root, written here as `$BASE`. All need
`Authorization: Bearer <token>` except `POST /api/login`.

### Authentication

| Method | Path         | Body                   | Returns                                  |
| ------ | ------------ | ---------------------- | ---------------------------------------- |
| POST   | `/api/login` | `{username, password}` | `{access_token, token_type, expires_in}` |

### Feature Refinery

| Method | Path                                      | Body / params                                            | Returns                                                                                                                       |
| ------ | ----------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| POST   | `/api/v1/feature-refinery`                | `{name, numberOfBuckets, trainFileName?, testFileName?}` | `{featureRefinementKey, uploadTargets[], startUrl, progressUrl}`                                                              |
| POST   | `/api/v1/feature-refinery/{key}/start`    | none                                                     | `{featureRefinementKey, state}`                                                                                               |
| GET    | `/api/v1/feature-refinery/{key}/progress` |                                                          | `{state, elapsedSeconds, progressCompleted, progressTotal, errorReason, baselineTopPerformances[], currentTopPerformances[]}` |
| GET    | `/api/v1/feature-refinery/{key}/result`   |                                                          | `{columnStatus[], refinedTrainUrl, refinedPredictionUrl, refinedColumnStatusUrl}`                                             |

`columnStatus[]` is one `{columnIndex, columnName, outcome}` per original column, in header order,
with `outcome` either `KEEP` or `REMOVE`. `refinedTrainUrl` and `refinedPredictionUrl` are the
reduced files; `refinedColumnStatusUrl` is the same decisions as a file, so the same columns can be
dropped from a later prediction file.
\| GET | `/api/v1/feature-refinery/{key}` | | `{state, inputs[], totalColumns, columnsRemoved, ...}` |
\| GET | `/api/v1/feature-refinery` | `offset`, `limit` (≤100) | `{refinements[], total}` |
\| POST | `/api/v1/feature-refinery/{key}/cancel` | none | `{state}` |
\| DELETE | `/api/v1/feature-refinery/{key}` | | `204` — non-destructive; retires the run from your list, keeps its data |

`name` must match `^[A-Za-z0-9 _-]{1,100}$` and be unique among your unfinished runs.
`numberOfBuckets` must be 4, 10 or 20.

### Datasets

| Method | Path                           | Body / params                                                          | Returns                                                                                                             |
| ------ | ------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| POST   | `/api/v1/datasets`             | `{datasetName, numberOfBuckets?, description?, featureRefinementKey?}` | `{datasetKey, status, uploadTargets[]}`                                                                             |
| POST   | `/api/v1/datasets/{key}/clean` | none                                                                   | `202`                                                                                                               |
| GET    | `/api/v1/datasets/{key}`       |                                                                        | `{status, metadata, inputs[], inputDatasource, recommendedThreshold, recommendedNumberOfBuckets, errors, warnings}` |
| GET    | `/api/v1/datasets`             | `offset`, `limit`, `state?`, `withModelOnly?`                          | `{datasets[], total}`                                                                                               |
| DELETE | `/api/v1/datasets/{key}`       |                                                                        | `204` — non-destructive; nothing is removed                                                                         |

`datasetName` is 3 to 30 characters. `numberOfBuckets` is 4 to 1000.

### Training

| Method | Path                                     | Body / params                           | Returns                                                                                          |
| ------ | ---------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------ |
| POST   | `/api/v1/training/datasets/{datasetKey}` | `{performanceThreshold, scalingFactor}` | `{trainingJobKey, status}`                                                                       |
| GET    | `/api/v1/training/{key}/progress`        |                                         | `{status, targetPerformance, jobs[], nextPollAfterSeconds}`                                      |
| GET    | `/api/v1/training/{key}`                 |                                         | `{status, achievedPerformance, modelKey, winnerAlgorithm, performance:{candidates[], champion}}` |
| POST   | `/api/v1/training/{key}/champion`        | `{attemptNumber, observations?}`        | `{attemptNumber, version, selectedBy, observations}`                                             |
| PATCH  | `/api/v1/training/{key}/cancel`          | none                                    | `{status}`                                                                                       |
| GET    | `/api/v1/training`                       | `offset`, `limit`, `parentOnly?`        | `{trainingJobs[], total}`                                                                        |
| GET    | `/api/v1/training/datasets/{datasetKey}` | `offset`, `limit`                       | `{trainingJobs[], total}`                                                                        |

### Models

| Method | Path                                   | Returns                                                |
| ------ | -------------------------------------- | ------------------------------------------------------ |
| GET    | `/api/v1/models/{modelKey}`            | `{modelKey, version, achievedPerformance, versions[]}` |
| GET    | `/api/v1/models/datasets/{datasetKey}` | `{models[], total}`                                    |

### Predictions

| Method | Path                                        | Body / params                                                   | Returns                                                                 |
| ------ | ------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- |
| POST   | `/api/v1/predictions`                       | query `modelKey`, `applyColumnStatusFilter?`; multipart `_file` | `{predictionKey, status}`                                               |
| POST   | `/api/v1/predictions/models/{modelKey}`     | multipart `_file`                                               | `{predictionKey, status}`                                               |
| GET    | `/api/v1/predictions/{key}/progress`        |                                                                 | `{status}`                                                              |
| GET    | `/api/v1/predictions/{key}`                 |                                                                 | `{status, modelVersion, predictionResultDownloadUrl, metadata, errors}` |
| GET    | `/api/v1/predictions/models/{modelKey}`     | `offset`, `limit`                                               | `{predictions[], total}`                                                |
| GET    | `/api/v1/predictions/datasets/{datasetKey}` | `offset`, `limit`                                               | `{predictions[], total}`                                                |

## State machines

Poll until terminal. Never match on intermediate states; they can change.

```yaml theme={null}
feature_refinement:
  terminal_success: [REFINEMENT_COMPLETED]
  terminal_failure: [ERROR, TIMEOUT, CANCELLED]
  progression: PENDING -> SETUP_* -> MEASURE_INITIAL_THRESHOLD* -> BASELINE_RUN_*
               -> REFINING_LOOP_* -> CREATING_RESULT -> REFINEMENT_COMPLETED
  poll_interval_seconds: 60
  typical_duration: tens of minutes to hours; scales with column count

dataset:
  terminal_success: [COMPLETED]
  terminal_failure: [FAILED]
  progression: PENDING -> PROCESSING -> COMPLETED
  poll_interval_seconds: 15

training_job:
  terminal_success: [COMPLETED]
  terminal_failure: [TIMED_OUT, CANCELLED, FAILED, NOT_COMPLETED]
  progression: PENDING -> RUNNING -> COMPLETED
  poll_interval_seconds: read nextPollAfterSeconds, else 15
  typical_duration: minutes on small data

prediction:
  terminal_success: [COMPLETED]
  terminal_failure: [FAILED, TIMEOUT]
  progression: PENDING -> RUNNING -> COMPLETED
  poll_interval_seconds: 15
```

## Uploading to a signed URL

`uploadTargets[]` entries look like:

```json theme={null}
{ "kind": "TRAIN",
  "url": "https://storage.googleapis.com/...",
  "requiredHeader": "X-Goog-Content-Length-Range:10,534773760" }
```

```yaml theme={null}
method: PUT
body: the raw CSV bytes
headers_required:
  - "Content-Type: text/csv"
  - the requiredHeader value, split on the FIRST colon (skip when null)
headers_forbidden:
  - Authorization        # breaks the signature -> 403
expiry: 60 minutes after the create call; no re-sign endpoint
on_expiry: create the resource again under a new name
```

## Decision rules a client must get right

These are the four places a naive client goes wrong.

### 1. Whether to call `/clean`

```
if dataset was created with featureRefinementKey:
    DO NOT call /clean        # creating it already started the work; /clean returns 409
else:
    PUT both files, THEN call /clean
```

### 2. Which training target to use

```
detail = GET /api/v1/datasets/{key}
if detail.recommendedThreshold is present:
    performanceThreshold = detail.recommendedThreshold
else:
    performanceThreshold = a value the data plausibly supports   # 0.7-0.8 on hard imbalanced data
```

Never default to 0.9 because it sounds better. A target above what the data supports produces a
degenerate champion: it predicts one class for every row, accuracy looks plausible, F1 is exactly
zero, and the run reports `COMPLETED`.

### 3. Validating the result before you trust it

After a run completes, check the champion before reporting success:

```
champion = run.performance.candidates[0]
if champion.f1Score == 0 or champion.recall == 0:
    warn: "degenerate model - the target was probably too high"
if champion.recall is null:
    # undefined, not zero. the test slice had no positive cases.
    warn: "recall undefined; check the class balance of the test file"
```

### 4. Bucket count on a refinement handoff

A dataset built from a refinement inherits `recommendedNumberOfBuckets` and it cannot be changed;
the refined files were produced at that setting. Send your own value only on the upload route.

## Error handling

```yaml theme={null}
400: bad input; message names the problem. NOT retryable as-is.
401: token missing or expired. Re-login ONCE, then retry. Second 401 = bad credentials.
403: on a signed URL only. Wrong headers or expired URL. Do not retry blindly.
404: unknown key, or not yours. Do not retry.
409: wrong state. Read the message:
       - "already exists"        -> name clash, pick another
       - "still needs ... upload"-> a file did not land
       - "already cleaned"       -> harmless on the handoff route, continue
       - "already started"       -> continue to polling
429: concurrency cap reached. Nothing was created. Wait and retry.
5xx: retry with exponential backoff, 3 attempts.
```

Retry-safe operations: every `GET`, and `DELETE` (idempotent, and non-destructive — nothing it
touches is destroyed, so a retry cannot lose anything).
Not retry-safe without checking first: every `POST` that creates something. On an ambiguous
failure, list and look before creating again.

## Reference CLI shape

A useful client exposes these commands. The tenant URL and credentials come from the environment;
everything else is a flag.

```
mathfi login                                   # verify credentials, cache the token
mathfi refine start --name N --train F --test F [--buckets 4|10|20]
mathfi refine status <key>                     # state + progress %
mathfi refine wait <key>                       # block until terminal, non-zero exit on failure
mathfi refine result <key> [--out DIR]         # column decisions + refined files
mathfi refine list

mathfi dataset create --name N (--from-refinement <key> | --train F --test F [--buckets N])
mathfi dataset status <key>
mathfi dataset list

mathfi train start --dataset <key> [--target T] [--scaling 19]   # target defaults to recommended
mathfi train wait <key>
mathfi train candidates <key>                  # the table you choose from
mathfi train champion <key> --attempt N [--why "..."]

mathfi predict --model <key> --file F [--out result.csv] [--filter-columns]

mathfi pipeline --name N --train F --test F --predict F --out result.csv
```

`mathfi pipeline` is the whole flow: refine, hand over, train at the recommended target, take the
default champion, predict, download.

Design notes worth keeping:

* **`wait` commands block and exit non-zero on failure.** That is what makes the CLI usable in a
  script.
* **Print state changes, not a tick per poll.** A refinement can poll for an hour.
* **`--json` on every read command.** Agents parse, humans read.
* **Never print the token.**
* **Fail fast on a missing `MATHFI_BASE_URL`** with a message that names the pattern. A client that
  silently defaults to some host will fail against a different tenant in a way nobody can read.

## Whole pipeline, as pseudocode

```python theme={null}
base = env("MATHFI_BASE_URL")          # https://<tenant>-api.mathfi.ai
token = login(base, email, password)

# 1. refine
fr = POST /api/v1/feature-refinery {name, numberOfBuckets: 10,
                                    trainFileName, testFileName}
for target in fr.uploadTargets:
    PUT target.url  headers={Content-Type: text/csv, **parse(target.requiredHeader)}
                    body=file_for(target.kind)        # NO Authorization
POST /api/v1/feature-refinery/{fr.key}/start
poll /progress every 60s until state in TERMINAL_FR
assert state == "REFINEMENT_COMPLETED"

# 2. dataset, by handoff
ds = POST /api/v1/datasets {datasetName, featureRefinementKey: fr.key}
# NO /clean here
poll /api/v1/datasets/{ds.key} every 15s until status in [COMPLETED, FAILED]
assert status == "COMPLETED"

# 3. train at the measured target
detail = GET /api/v1/datasets/{ds.key}
job = POST /api/v1/training/datasets/{ds.key}
        {performanceThreshold: detail.recommendedThreshold, scalingFactor: 19}
poll /api/v1/training/{job.key}/progress until status in TERMINAL_TRAIN
                                          sleeping nextPollAfterSeconds
assert status == "COMPLETED"

# 4. inspect, then choose
run = GET /api/v1/training/{job.key}
candidates = run.performance.candidates      # best first, by fewest falseDecisions
if candidates[0].f1Score == 0: warn("degenerate model; target was too high")
# optional: POST /api/v1/training/{job.key}/champion {attemptNumber: chosen}

# 5. predict
pred = POST /api/v1/predictions?modelKey={run.modelKey}&applyColumnStatusFilter=true
         multipart _file=unlabelled.csv
poll /api/v1/predictions/{pred.key}/progress until status in TERMINAL_PRED
download GET /api/v1/predictions/{pred.key} -> predictionResultDownloadUrl
```

## Things that will bite you

|                                                                                       |   |
| ------------------------------------------------------------------------------------- | - |
| There is no shared host: the API root is per tenant, `https://<tenant>-api.mathfi.ai` |   |
| The login field is `username` but holds an **email**                                  |   |
| The multipart part is `_file`, with the underscore                                    |   |
| `offset` and `limit` are **required** on list endpoints                               |   |
| Signed uploads must not carry `Authorization`                                         |   |
| `/clean` on a refinement handoff returns `409`                                        |   |
| A `null` metric means undefined, not zero                                             |   |
| Signed URLs die after 60 minutes, with no way to re-sign                              |   |
| Refinement results vary run to run on identical input                                 |   |
| A `COMPLETED` training run can still hold a useless model; check F1                   |   |

<CardGroup cols={2}>
  <Card title="Working curl flows" icon="terminal" href="/developers/api-recipes">
    The same steps, runnable.
  </Card>

  <Card title="Endpoint reference" icon="list" href="/api-reference/feature-refinery/create-feature-refinement">
    Schemas and responses, generated from the spec.
  </Card>
</CardGroup>
