# REST API

Call the REST API from any language: post a sequence to `POST /v1/tasks/<task>/predict` with an API key, get back typed JSON. This page walks the contract, then the [integration kit](#3-get-the-kit) — a single-file Python client and runnable recipes that let you reproduce every call in a few minutes.

Base URL: `https://api.genomicintelligence.ai`

## 1. The contract

Every endpoint shares one shape:

- **Six tasks**, one operation each: `POST /v1/tasks/promoter/predict`, `/splice/predict`, `/enhancer/predict`, `/chromatin/predict`, `/annotation/predict`, `/expression/predict` — plus two workflows, `POST /v1/workflows/find-genes-and-predict-expression` and `POST /v1/workflows/genomic-variant-interpretation`.
- **API key** on every `/v1/*` route, sent as `Authorization: Bearer gi_…`. Public (no key): `/health`, `/docs`, `/redoc`, `/v1/openapi.json`, and `GET /v1/tasks/{task}/models` — capability discovery needs no key, so you can size a request against a model's `bio_spec` before onboarding. It is metered per source IP rather than per partner, so a burst of discovery calls can `429`.
- **One success envelope** — `{data, meta}` on every predict and workflow 2xx, including the `202`. `data` is task-specific; `meta` is uniform across tasks. The read-only endpoints are deliberately bare: `GET /v1/tasks/{task}/models` returns `{task, default_model, models}` and `GET /v1/tasks/jobs` returns `{jobs, count}`. `meta` describes the computation that produced a result — `model`, `cold_start`, timings, `job_id` — so an endpoint that runs no inference has nothing to put there.
- **One error envelope** — `{error: {code, message, request_id, details?}}`. Switch on `error.code`, never the message.
- **Tell us what is calling** — optional. `X-GI-Client-App: <your tool or group>` (and `X-GI-Client-App-Version`) is recorded against the request and changes nothing about auth, routing or the response. It is worth setting if your key is shared across a team, a course or an event: without it every caller on one key is one caller to us, and we cannot tell your traffic from anyone else's when you ask about your own usage. Up to 64 characters (32 for the version); control characters are stripped.
- **Sync by default, async on request** — `Prefer: respond-async` is a declared header parameter on all six predict operations and the composite: the call returns `202` with a `job_id`, and you poll `GET /v1/tasks/jobs/{job_id}`.
- **Per-task bodies.** Each task has its own request schema, its own minimum `sequence` length, and its own **closed** `options` object — there is no shared `PredictRequest` any more. The URLs are unchanged, so client *code* needs no new URL construction, but a typed SDK generated from the old document must be regenerated. See [Tasks](tasks.md#options) and [Limits](reference/limits.md#input-length-per-task).

The live [OpenAPI schema](https://api.genomicintelligence.ai/v1/openapi.json) (browse it in [ReDoc](https://api.genomicintelligence.ai/redoc)) is the machine-readable source of truth for every field.

**Pin the contract revision.** The document carries a top-level `x-contract-revision` — a monotonic integer that changes whenever anything partner-visible in it changes, and only then. Record the value you built against and compare it on a schedule; you get a one-integer answer to "has the contract moved?" without diffing the document. A version bump alone never moves it.

> The six-operation document, the typed `options`, the per-task minimums, and the published composite are rolling out to `api.genomicintelligence.ai` now. If a schema you fetched still reports a single templated `POST /v1/tasks/{task}/predict`, you have the previous version — the request URLs and response bodies are identical either way.

## 2. Get a key

Email [contact@genomicintelligence.ai](mailto:contact@genomicintelligence.ai) for a `gi_…` API key. The same key works for the REST API and the [MCP server](mcp.md).

Verify it in one call:

```bash
curl -sS https://api.genomicintelligence.ai/health
# {"status":"healthy","version":"YYYY.MM.DD.iter (commit)"}
```

## 3. Get the kit

Browse the code in your browser — the [Python client](/integration-kit/client)
and [recipes](/integration-kit/recipes) render with syntax highlighting — or
**[download the integration kit](https://docs.genomicintelligence.ai/integration-kit.tar.gz)**
(`.tar.gz`) to run it locally. Fetch it inline:

```bash
curl -L https://docs.genomicintelligence.ai/integration-kit.tar.gz | tar -xz
cd integration-kit/
```

Inside:

| Path | What it is |
|---|---|
| `client/gi_client.py` | Single-file client. Depends only on `requests`. Drop it into your project. |
| `client/quickstart.py` | Runs every task end to end on real bundled sequences. |
| `client/sequences/` | Real FASTA fixtures, one per task. |
| `recipes/` | Five self-contained integration patterns (see [Recipes](#6-recipes)). |

## 4. Run the quickstart

This hits every task with a real biological sequence and prints the result — the fastest way to see the API work:

```bash
cd integration-kit/client
pip install -r requirements.txt
export GI_API_KEY=gi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
python quickstart.py
```

## 5. Call a task

Drop `gi_client.py` into your project and call a task. Every prediction returns the full `{data, meta}` body:

```python
import os
from gi_client import Client

client = Client(api_key=os.environ["GI_API_KEY"])

body = client.predict("promoter", sequence="ACGT" * 500, sequence_name="demo")
print(body["meta"]["model"], body["meta"]["inference_time_ms"], "ms")
print(body["data"]["regions"])
```

Two things to get right on any task:

- **Mind the per-task minimum.** Promoter needs ≥ 300 bp, splice 100, enhancer 50, chromatin 200, annotation 1,000, expression 9,198 — measured after whitespace is stripped, and enforced before any model loads. Under it (or over 500,000 bp) is `422 validation_failed`, not `413`. Being *above* the floor is not the same as being in regime: if your sequence is shorter than the chosen model's `context_window_bp`, it is scored against a padded window. [Limits](reference/limits.md#the-floor-is-admission-control-not-regime) has the table.
- **`options` is closed.** Each task accepts a fixed set of keys — an unknown one is `422 validation_failed` with `loc` `["body","options","<key>"]`. In particular, choose a model with the top-level `model=` argument, never `options={"model": …}`. The per-task keys are in [Tasks](tasks.md#options).

The `expression` task requires an experimental-context description and a TSS position. Every expression model takes at least 9,198 bp with the TSS at least 4,599 bp from each end. Longer TSS-centred sequence is better, and the model list says how much: build a gene-sense sequence with the TSS at that offset, and pass `tss_index`, the **0-based offset of the TSS into the whitespace-stripped sequence**. Do not hard-code a model id or a flank; read both from the list, so a new default or a longer-window model needs no change on your side. Mind the strand when you fetch:

```python
listing = client.list_models("expression")
model = listing["default_model"]   # or the model your user picked; resolve once per run
# One fetch that serves every listed model: the largest recommended flank (the
# server ignores flank beyond what a model reads), never below the 4,599 bp
# per-side floor, and with 2F within the request cap.
flank = max([4599] + [m["bio_spec"].get("recommended_flank_bp") or 0 for m in listing["models"]])
flank = min(flank, min(m["bio_spec"]["request_max_bp"] for m in listing["models"]) // 2)
# tss = 0-based genomic position of the TSS base. fetch() and revcomp() are your own.
if strand == "+":
    locus = fetch(chrom, tss - flank, tss + flank)                  # [tss - F, tss + F)
else:
    locus = revcomp(fetch(chrom, tss - flank + 1, tss + flank + 1)) # (tss - F, tss + F], then reverse-complemented
# Either way the TSS now sits at offset `flank` of the gene-sense sequence.

body = client.predict(
    "expression",
    sequence=locus,                      # 2 * flank bp; at least 9,198, up to 500,000
    sequence_name="HBB",
    model=model,                         # explicit, so a run never straddles a default change
    tss_index=flank,                     # required unless len(sequence) == 9198
    options={"description": "polyA plus RNA-seq; Homo sapiens K562"},
)
print(body["data"]["model"], body["data"]["prediction"]["expression_log_tpm"], "log(TPM+1)")
# Confirm the server read the region you meant; its width depends on the model.
print(body["meta"]["task_specific_counts"]["scored_window"])
```

The same body works with any listed model: pass another id as `model` and the server cuts what that model reads around `tss_index`. Near a chromosome end, use the largest symmetric flank that fits, down to 4,599 bp. A submission of exactly 9,198 bp centred on the TSS needs no `tss_index` (it defaults to `4599`), but it is the floor, not a target: a model that reads more flank than that scores less than it was trained on.

Sequences shorter than 9,198 bp, or with the TSS closer than 4,599 bp to either end, are rejected with `422 validation_failed`; there is no padding or truncation fallback. A window dominated by `N` or low-complexity repeats can also be refused, because each model needs a minimum of real sequence on each side of the TSS. A `tss_index` computed against line-wrapped FASTA *characters* rather than the stripped nucleotide string will be off by its newline count, which can silently score the wrong region; always check the echoed `scored_window`. Full rules: [Limits](reference/limits.md#expression-sequence-length-and-tss_index).

Per-task inputs, options, and outputs are on the [Tasks](tasks.md) page.

### Asynchronous jobs

For long inputs, submit asynchronously and poll. The client handles both steps — submit returns a `job_id`, and `wait_for_job` polls until the job is done:

```python
job_id = client.submit_async(
    "annotation", sequence=long_sequence, sequence_name="chr8:1-120000",
)
result = client.wait_for_job(job_id, on_progress=print)
print(len(result["data"].get("transcripts", [])), "transcripts")
```

`Prefer: respond-async` is a declared header parameter on all six predict operations and on both workflows — **required** on [genomic-variant-interpretation](tasks.md#genomic-variant-interpretation), which has no synchronous mode, and required for an [annotation](tasks.md#annotation) JSON response above 200,000 bp; optional everywhere else. The `202` body is the same `{data, meta}` envelope as a sync `200`, with `data = {job_id, status: "accepted", links}`; the id is also on the `Content-Location` and `X-Job-Id` response headers. Polling `GET /v1/tasks/jobs/{job_id}` returns `202` with `data = {job_id, status, progress}` while the job runs, then `200` with the result. Async is JSON-only — pairing it with a text `?format=` is a `400`.

Use async above the per-task size thresholds in [Limits](reference/limits.md); below them, sync is simplest.

### Find genes, then predict their expression

`POST /v1/workflows/find-genes-and-predict-expression` chains annotation and expression server-side: it finds the genes in a region, cuts a window around each TSS, and scores each gene with the API's default expression model, using as much flank around each TSS as the submitted region holds, so longer regions give better predictions, so you never compute a `tss_index`.

```python
body = client.find_genes_and_predict_expression(
    sequence=region,                 # 1,000–500,000 bp, plus strand
    sequence_name="chr8:127,680,000-127,800,000",
    description="polyA plus RNA-seq; Homo sapiens K562",
    async_=True,                     # required above 50,000 bp
)
result = client.wait_for_job(body["data"]["job_id"])
print(result["meta"]["task_specific_counts"])   # {genes_found, genes_predicted, genes_skipped}
for gene in result["data"]["expression_predictions"]:
    print(gene.get("skip_reason") or gene["prediction"]["expression_log_tpm"])
```

Submitted synchronously above 50,000 bp it returns `413 sync_too_large` with `details = {sequence_length, threshold}`; resend the identical body with `async_=True`. `POST /v1/tasks/annotation/predict` behaves the same way above 200,000 bp, but only for a **JSON** response: `?format=bed` and `?format=gff3` have no cap and stay synchronous at any accepted length, because async delivery is JSON-only and pairing `Prefer: respond-async` with a text format is a `400`. Those two operations are the contract's only sync caps. Full contract: [Tasks](tasks.md#find-genes-and-predict-expression).

### Interpret the variants in a VCF

`POST /v1/workflows/genomic-variant-interpretation` is the one operation that takes no DNA `sequence`. Point it at a VCF in your own object storage; it scores the variants near a transcription start site and writes an annotated copy beside it, named after the input and the job — read the exact name from `data.output.uri`. The JSON is a receipt — where the file is and how the run went — so you never parse a VCF to drive the workflow.

```http
POST /v1/workflows/genomic-variant-interpretation
Authorization: Bearer gi_...
Prefer: respond-async
```

```json
{
  "input": { "type": "s3_object", "uri": "s3://your-bucket/cases/CASE-00123/sample.vcf.gz" },
  "client_ref": "CASE-00123",
  "options": { "genome_build": "GRCh37" }
}
```

`Prefer: respond-async` is **required** here — a case runs for hours, so there is no synchronous mode and a request without the header is `400`. Poll `GET /v1/tasks/jobs/{job_id}` as above. Resubmitting the same case returns the job you already have rather than starting a second run. Full contract, including the annotation format and why `meta.model` may be absent: [Tasks](tasks.md#genomic-variant-interpretation).

### Error handling

Every non-2xx raises `GIError`, carrying the envelope's fields. Switch on `code`, not HTTP status:

```python
from gi_client import Client, GIError

try:
    client.predict("promoter", sequence="ACGT" * 500, sequence_name="demo")
except GIError as exc:
    print(exc.code, exc.message, exc.request_id)
```

Every `error.code` and how to handle it is in [Errors](reference/errors.md).

## 6. Recipes

Self-contained scripts that each show one pattern. [Read them all in the
browser](/integration-kit/recipes), or link straight to the raw source:

| Intent | Recipe |
|---|---|
| Verify connectivity and key | [`01_health_probe.py`](https://docs.genomicintelligence.ai/recipes/01_health_probe.py) |
| Promoters across a gene list | [`02_promoters_for_gene_list.py`](https://docs.genomicintelligence.ai/recipes/02_promoters_for_gene_list.py) |
| Async annotation with polling | [`03_async_annotation_polling.py`](https://docs.genomicintelligence.ai/recipes/03_async_annotation_polling.py) |
| Rate-limit-aware retry | [`04_ratelimit_aware_retry.py`](https://docs.genomicintelligence.ai/recipes/04_ratelimit_aware_retry.py) |
| Typed error handling | [`05_typed_error_handling.py`](https://docs.genomicintelligence.ai/recipes/05_typed_error_handling.py) |

## Client reference

`gi_client.py` exposes:

- `Client(api_key, base_url=…, timeout=…)` — session configured with your key.
- `predict(task, sequence, sequence_name, model=None, options=None, tss_index=None)` — synchronous; returns `{data, meta}`. `model` is top-level, not an `options` key.
- `submit_async(...)` — same arguments, submitted with `Prefer: respond-async`; returns a `job_id`.
- `find_genes_and_predict_expression(sequence, description, sequence_name=…, options=None, async_=False)` — the composite workflow.
- `wait_for_job(job_id, poll_interval=2.0, on_progress=None)` — poll until terminal.
- `list_models(task)`, `list_jobs(limit=…)`, `health()` — supporting calls. `list_models` returns the flat `{task, default_model, models}` object, each model carrying `bio_spec.request_max_bp` / `context_window_bp` / `trained_window_bp` (and, for expression, `recommended_flank_bp`).
- `TASK_MIN_BP`, `MAX_BP`, `FIND_GENES_SYNC_LIMIT_BP` — local mirrors of the published bounds, for pre-flight checks. The authority is the served schema, not these constants.
- `GIError` — raised on any non-2xx, carrying `code`, `message`, `request_id`, `details`.

## Output formats

Predictions return JSON in the `{data, meta}` envelope by default. Tasks that also emit text tracks (BED, bedGraph, GFF3) expose them via the `Accept` header or a `?format=` query parameter; the [Tasks](tasks.md) page lists the formats each one supports. An unsupported value is `415 unsupported_format` — never a silent fallback to JSON — and text formats are synchronous-only, so combining one with `Prefer: respond-async` is a `400`.

### GFF3 `source` (column 2)

In every GFF3 this API **generates**, column 2 is the id of the model that
produced the feature — the same value as `data.model` — so a saved track
records its own provenance:

```
chr1	g0-splice-bigbird	splice_donor	68	73	0.9949	.	.	ID=SD_1
```

> Splice GFF3 previously emitted the literal `gpu_service` here. Saved files
> produced before revision 4 carry the old value; nothing else about the line
> changed. If you parse column 2, read it as the model id.

The one exception is annotation's `g0-annotation`, whose upstream pipeline
emits its own canonical GFF3. That document is passed through **verbatim**, so
its column 2 reads `GENATATOR-PIPELINE` — a four-stage pipeline rather than a
single model, which a single model id would misdescribe.

## Next steps

- [Tasks](tasks.md): per-task input sizes, options, outputs, and strand-sensitivity.
- [Errors](reference/errors.md): every `error.code` and how to handle it.
- [Limits](reference/limits.md): per-task length caps, rate quotas, async TTL.
- [MCP server](mcp.md): the same six tasks as agent tools.
- [OpenAPI schema](https://api.genomicintelligence.ai/v1/openapi.json) · [ReDoc](https://api.genomicintelligence.ai/redoc): the full machine-readable contract.
