# Tasks

Six tasks, each its **own published operation** — `POST /v1/tasks/promoter/predict`, `/v1/tasks/splice/predict`, `/v1/tasks/enhancer/predict`, `/v1/tasks/chromatin/predict`, `/v1/tasks/annotation/predict`, `/v1/tasks/expression/predict` — plus two [workflows](#find-genes-and-predict-expression) that compose them. The URLs are exactly the strings you already POST to; what changed is that each now has its own request schema, its own `minLength`, and its own closed `options` object, instead of one shared `PredictRequest`. (Regenerate typed clients built against the old shared model.) The same six tasks are also tools over [MCP](mcp.md).

Each accepts a DNA `sequence` (and a `sequence_name` label) and returns task-specific fields under `data`; `meta` is uniform across tasks. Request and response schemas — with copyable examples — are in [ReDoc](https://api.genomicintelligence.ai/redoc); per-task length caps and latency are in [Limits](reference/limits.md).

| Task | Input (bp) | Model window | Strand-sensitive | Output |
|---|---|---|---|---|
| [Promoter](#promoter) | 300–500,000 | 2,000 (300 on the `*-300bp` models) | Yes (coding strand) | Promoter-region probabilities |
| [Splice](#splice) | 100–500,000 | 15,000 | Yes (transcript orientation) | Donor / acceptor sites |
| [Enhancer](#enhancer) | 50–500,000 | 249 | Yes (dev/hk channels) | `dev` + `hk` activity scores |
| [Chromatin](#chromatin) | 200–500,000 | 1,000 | No | 919 features in 8 track groups |
| [Expression](#expression) | 9,198–500,000 (+ `tss_index`) | — (`recommended_flank_bp` per model) | Yes (coding strand) | Expression in log(TPM+1) |
| [Annotation](#annotation) | 1,000–500,000 | — | Plus-strand input | Transcripts with type + exon/intron/CDS structure (GFF3) |
| [Find genes + expression](#find-genes-and-predict-expression) | 1,000–500,000 | — | Plus-strand input | Genes found, each with a predicted expression |
| [Variant interpretation](#genomic-variant-interpretation) | A VCF, not a sequence | — | Coordinates, not strand | An annotated copy of your VCF, written back to your bucket |

Where a task is marked strand-sensitive, submit DNA 5'→3' on the gene's coding (sense) strand; for annotation, submit the plus-strand region.

`GET /v1/tasks/{task}/models` returns `{task, default_model, models}`; each model's `is_default` marks the default. The model-list endpoint is authoritative for what each task currently serves.

The "input" column is the **accepted** range, enforced before any model loads. The "model window" is the model's own `context_window_bp`: a sequence above the floor but below that window is still scored — against a window padded out to it. See [the floor is admission control, not regime](reference/limits.md#the-floor-is-admission-control-not-regime).

## Model selection

Each task has a `default_model` and may offer alternatives. List them with `GET /v1/tasks/{task}/models` (a flat `{task, default_model, models}` object, not the `{data, meta}` envelope; it is public, no key needed, and an unknown task is `404 not_found`). To pick one, pass its id in the **top-level `model` field of the request body** — *not* `options.model`: every task's `options` object is closed, so an unrecognised key there is `422 validation_failed` with `loc` `["body","options","model"]`.

Each model's `bio_spec` carries `request_max_bp` (the enforced ceiling), `context_window_bp` (its own window), `trained_window_bp` (a fixed receptive field where it has one) and, for expression, `recommended_flank_bp` (how much sequence to send on each side of the TSS) — see [Limits](reference/limits.md#the-floor-is-admission-control-not-regime). Beyond the default human models, `promoter` offers species-specific variants (Drosophila, yeast, Arabidopsis), and DNABERT k-mer variants exist for `promoter`, `enhancer`, and `chromatin`; `expression` offers more than one model, with longer-window models added over time; `default_model` names the current default. The model-list endpoint is authoritative for what each task currently serves; the same roster and default are also published statically as `x-models` on each predict operation in [`/v1/openapi.json`](https://api.genomicintelligence.ai/v1/openapi.json), for tooling that wants it without a live call.

## Options

`options` is typed and **closed** per task: unknown keys are rejected with `422 validation_failed` (`type: extra_forbidden`) rather than ignored. The complete set:

| Task | `options` keys | Required |
|---|---|---|
| Promoter | `threshold` (0–1, default 0.5) | — |
| Splice | `threshold` (0–1, default 0.5), `site_types` (subset of `["donor","acceptor"]`, default both) | — |
| Enhancer | *none — the object accepts no fields* | — |
| Chromatin | `threshold` (0–1, default 0.5) | — |
| Annotation | `shift_coordinates` (string, e.g. `"UCSC"`), `reverse_complement` (bool, default `true`) | — |
| Expression | `description` (string) | **`description`**, and `options` itself |
| Find genes + expression | `description`, `annotation_model`, `expression_model`, `shift_coordinates` | `options` itself; `description` enforced at runtime |

## The tasks, one card each

Each task below has its own notes, an example call from the [kit client](https://docs.genomicintelligence.ai/integration-kit/client) and from an [MCP](mcp.md) host, then **Applications** and **Usage tips**. On the rendered page ([docs.genomicintelligence.ai/tasks](https://docs.genomicintelligence.ai/tasks)) each card also carries Input, Options and Output panels, generated at build time from the pinned [`/v1/openapi.json`](https://api.genomicintelligence.ai/v1/openapi.json) so they cannot drift from it; **Source** on a panel opens that operation in ReDoc. The prose does not restate those schemas; outside the panels, read them in [ReDoc](https://api.genomicintelligence.ai/redoc) or in `/v1/openapi.json` itself.

## Promoter

Binary classification that detects promoter regions. The default `g0-promoter-2000bp` is a G0 BERT-Large encoder trained on human promoters from EPDnew. Inputs above 2,000 bp are scanned in windows automatically.

- `sequence`: floor set by the smallest `context_window_bp` among the promoter models (bounds in the table above); below `g0-promoter-2000bp`'s own 2,000 bp window the input is padded out to it.
- `options.threshold` (0–1, default 0.5): probability cutoff for calling a window a promoter. The only key `PromoterOptions` accepts.
- Strand-sensitive — submit the coding (sense) strand.

Output: per-window promoter probabilities. Also `BED` and `bedGraph` — `?format=json|bed|bedgraph` (see [output formats](rest-api.md#output-formats)).

### API reference

**Python client**

```python
import os
from gi_client import Client  # the kit's single-file client

client = Client(api_key=os.environ["GI_API_KEY"])  # sent as Authorization: Bearer gi_...
r = client.predict("promoter", sequence=seq, options={"threshold": 0.5})
regions = r["data"]["regions"]
```

**MCP**

Ask the [MCP server](mcp.md): *"Fetch a 100 kb window around human TP53 and scan it for promoter regions."*

### Applications

Use it to locate transcription start regions in unannotated sequence, or to score designed constructs for promoter strength inside an optimisation loop. The per-window detail in `data.window_details` makes it usable as a fitness signal rather than only a binary call.

### Usage tips

- **The floor is 300 bp** — the `minLength` on this operation's request schema. It is a property of the endpoint, so choosing a different `model` does not lower it.
- **Input shorter than the model's window is padded, not rejected.** A 300 bp submission is scored against `g0-promoter-2000bp`'s 2,000 bp window; compare your length with `bio_spec.context_window_bp` from `GET /v1/tasks/promoter/models` before reading much into a marginal call.
- **Submit the coding (sense) strand.** The task is strand-sensitive.

## Splice

Token-level classification labeling each position as a splice acceptor (3' site), donor (5' site), or neither. The default `g0-splice-bigbird` is a G0 BigBird encoder trained on human splice sites from SpliceAI. Long inputs are scanned in a 15,000 bp sliding window; predictions are most reliable in the central region of each window.

- `sequence`: the floor (bounds in the table above) is well below the model's 15,000 bp window, so short submissions are scored against heavy padding — treat 15,000 bp as the in-regime size.
- `options.threshold` (0–1, default 0.5): score cutoff for emitting a site. A response carrying more than **20,000 sites** is refused with `422 validation_failed` — only a very low threshold on a long sequence gets there. If you want everything, use `1e-3`, not `0`: it is numerically equivalent (no score moved by more than 4.6e-4 in our benchmark), ~4x faster, and stays under the cap. See [Limits](reference/limits.md#response-size-the-splice-site-cap).
- `options.site_types`: subset of `["donor", "acceptor"]` (default both). With `threshold`, the only two keys `SpliceOptions` accepts.
- Strand-specific — submit the transcript's orientation. The wrong strand does not fail loudly, and it does not return near-zero scores either: it returns sites at different positions, often still at high confidence. On our BRCA1 fixture the reverse complement scored 0.9101 and 0.9946 where gene-sense scored 0.9998 and 0.9999; on SMN1 it returned 1 site instead of 17. Neither the site count nor the scores give you a usable check, and there is no `strand` flag in the contract, so track orientation on your side.

- **`start`/`end` bound the scored token, not the junction.** The model reads your sequence as BPE tokens and scores whole tokens, so a site's span is that token's character range — typically 4–8 bp, and varying from site to site because token lengths do. A donor or acceptor junction is a single base. `token_index` names the token the span came from. Do not treat the span as the feature's extent, and be careful intersecting it with a base-resolution reference annotation: at that scale it will be quietly wrong. We publish no derived base position — deriving one from a token score would be a modelling claim we cannot currently defend, and invented precision in a file you keep is worse than an honest span. Stated in the contract since revision 5.

Output: detected acceptor and donor sites with scores. Also `BED` and `GFF3` — `?format=json|bed|gff3` (see [output formats](rest-api.md#output-formats)). The GFF3 `source` column carries the model id (`g0-splice-bigbird`); it read `gpu_service` before contract revision 4. **Columns 4–5 of that GFF3 are the token spans described above**, so a saved track has token resolution, not base resolution.

### API reference

**Python client**

```python
# client = Client(api_key=os.environ["GI_API_KEY"]), as in Promoter
r = client.predict("splice", sequence=seq, options={"threshold": 1e-3, "site_types": ["donor", "acceptor"]})
sites = r["data"]["sites"]  # start/end bound a token, not the junction base
```

**MCP**

Ask the [MCP server](mcp.md): *"Fetch the human HBB gene sequence and predict its splice sites."*

### Applications

Use it to locate exon boundaries in unannotated transcripts, to check whether a designed edit creates or destroys a splice site, or to screen variants for splice disruption by scoring the reference and the edited sequence side by side.

### Usage tips

- **Submit the transcript's orientation.** The wrong strand does not fail loudly: it returns sites at different positions, often at high confidence, and no score or count identifies a mis-oriented submission afterwards. Reverse-complement minus-strand genes before you call.
- **A site's `start`/`end` is a token span, not the junction base** — typically 4–8 bp. Locate a boundary to within the span; do not reduce it to one base or intersect it with base-resolution annotation as though it were one.
- **The floor is 100 bp, the window 15,000 bp.** Treat 15,000 bp as the in-regime size.
- **Do not send `threshold: 0`.** A response over 20,000 sites is refused with `422 validation_failed`; `1e-3` returns effectively everything and stays under the cap.

## Enhancer

Regression predicting two scores per window: developmental (`dev`) and housekeeping (`hk`). The default `g0-deepstarr` is a G0 BERT-Base encoder trained on Drosophila enhancers from DeepSTARR. The sequence is split into consecutive, non-overlapping 249 bp windows, each scored independently.

- `sequence`: the floor (bounds in the table above) is admission control — the shortest input `g0-deepstarr` will admit — not a useful size: the model scores 249 bp windows, so anything shorter is accepted and scored against a padded window. Submit ≥ 249 bp to stay in regime.
- `options`: **`EnhancerOptions` accepts no fields.** Send `{}` or omit it; any key is a `422`. There is no threshold on this task — it is regression, not classification.

The model is Drosophila-trained and strand-sensitive for the dev/hk channels — use a fly sequence on the gene's coding (sense) strand.

Output: per-window `dev` and `hk` scores, embedding bedGraph renderings of each track. `bedGraph` is also available directly — `?format=json|bedgraph` (see [output formats](rest-api.md#output-formats)).

### API reference

**Python client**

```python
# client = Client(api_key=os.environ["GI_API_KEY"]), as in Promoter
r = client.predict("enhancer", sequence=fly_seq)  # no options: EnhancerOptions accepts none
windows = r["data"]["windows"]
```

**MCP**

Ask the [MCP server](mcp.md): *"Fetch the Drosophila ftz gene and predict enhancer activity."*

### Applications

Use it to rank candidate enhancers, or as a dual-objective fitness function when designing regulatory elements that favour one programme (developmental or housekeeping) over the other.

### Usage tips

- **The floor is 50 bp**, the lowest of the six, but the model scores 249 bp windows. Submit at least 249 bp to stay in regime.
- **The developmental/housekeeping split is a *Drosophila* STARR-seq definition** (DeepSTARR). Read the two scores as relative activity within a comparison, not as calibrated cross-species values.
- **Send no options.** `EnhancerOptions` accepts no fields: send `{}` or omit it; any key is a `422`.

## Chromatin

Multi-label classification predicting 919 mammalian chromatin features (DeepSEA-style), grouped into eight categories (DNase, CTCF, Pol2, c-Myc, H3K27ac, H3K27me3, H3K4me1, Other). The default `g0-deepsea` is a G0 BERT-Base encoder trained on ENCODE data. The sequence is tiled into 1,000 bp windows stepping every 200 bp.

- `sequence`: the model's window is 1,000 bp (floor in the table above), so submit at least that to avoid padding.
- `options.threshold` (0–1, default 0.5): only features scoring above this are returned. The only key `ChromatinOptions` accepts.

Output: per-feature probabilities (above the threshold), grouped by category. Also `BED` — `?format=json|bed` (see [output formats](rest-api.md#output-formats)).

### API reference

**Python client**

```python
# client = Client(api_key=os.environ["GI_API_KEY"]), as in Promoter
r = client.predict("chromatin", sequence=seq, options={"threshold": 0.5})
windows = r["data"]["windows"]
print(r["meta"]["task_specific_counts"]["total_annotations"])
```

**MCP**

Ask the [MCP server](mcp.md): *"Fetch the human HBB promoter region and predict its chromatin features."*

### Applications

Use it to ask whether a sequence looks accessible, bound, or marked, and to compare designed variants against a natural reference across many assays at once.

### Usage tips

- **The floor is 200 bp; the window is 1,000 bp.** Submit at least 1,000 bp to avoid padding.
- **Lowering `threshold` grows the response.** Only features scoring above it are returned, and each window is scored on all 919.
- **A count is not a cell type.** `meta.task_specific_counts.total_annotations` sums calls across every window and every feature; a high count is not evidence about any one cellular context. Read the individual features for that.

## Expression

Predicts gene expression from a DNA sequence plus an experimental-context description, on a log(TPM+1) scale. The models are trained on ENCODE RNA-seq across cell types and differ in how much flank around the TSS they read; the [model list](#model-selection) names the default, and the top-level `model` field selects another.

Expression has its own operation and request schema — `POST /v1/tasks/expression/predict`, `ExpressionPredictRequest` — as every task now does. It remains the only one that takes a `tss_index` and the only one whose `options` has a required field.

The body accepts exactly five fields — `sequence` (required), `options` (required), `tss_index`, `sequence_name`, `model` — and is closed: any other top-level key returns `422 validation_failed`.

- **`sequence`** (required): **at least 9,198 bp, up to 500,000 bp**, gene-sense strand, with the TSS at least 4,599 bp from each end. Longer TSS-centred sequence is better, and the model list says how much: put the TSS at offset `F` of the gene-sense sequence, `F` the largest `bio_spec.recommended_flank_bp` in [`GET /v1/tasks/expression/models`](#model-selection) (never below 4,599, `2F` within `request_max_bp`), and send `tss_index = F`. In 0-based genomic coordinates that is `[tss − F, tss + F)` on the plus strand, and `(tss − F, tss + F]` reverse-complemented on the minus strand; see [Limits](reference/limits.md#expression-sequence-length-and-tss_index). Shorter input is a hard `422`, never padded or truncated. Lengths are counted after whitespace is stripped, so a line-wrapped FASTA body pastes verbatim (the `>` header line does not).
- **`tss_index`** (integer, 0-based): offset of the TSS **into the whitespace-stripped `sequence`**. Required unless `sequence` is exactly 9,198 bp, where it defaults to `4599`. Must satisfy `4599 <= tss_index <= len(sequence) - 4599`. The server cuts what the model reads around it; how much depends on the model. It does **not** discover the TSS for you and does **not** reverse-complement. There is no `tes_index`. See [Limits](reference/limits.md#expression-sequence-length-and-tss_index) for the full rules, the silent-wrong-window gotcha, and the `422` for windows with too little real (non-`N`) sequence.
- **`model`** (optional, top-level): any id from the model list; omitted, the listing's `default_model`. Pass it explicitly when comparing calls, so they never straddle a change of default.
- **`options.description`** (required): free-text experimental context (cell type, assay, conditions). `options` is a closed object — `description` is its only accepted key, and it must be non-empty. Omitting it returns `422 validation_failed`.

  This string is **model input, not a label.** It conditions the prediction, and the exact wording changes the number: on one fixture, same sequence and same model, `"K562"` scored 1.38 and `"K562 cells"` scored 0.70 log(TPM+1) — roughly a 2x spread in TPM from phrasing alone. There is no enum and no canonical vocabulary. Pick one form and keep it fixed: when you compare predictions across sequences, runs, or releases, a changed `description` invalidates the comparison as surely as a changed sequence.

Output: `data.prediction` = `{expression, expression_log_tpm, expression_tpm, unit}` — `expression`/`expression_log_tpm` are log(TPM+1); `expression_tpm` is the back-transformed linear value. JSON only (no text-track formats; `?format=` anything but json is `415 unsupported_format`).

Windowing provenance is echoed back: `meta.task_specific_counts` carries `tss_index` and `scored_window` (`data.input` echoes `tss_index` only) — `scored_window` is the half-open `[start, end]` of your submitted sequence that the model actually read. Its width, `scored_window[1] - scored_window[0]`, depends on the model and shows how much of your flank was used; log it, do not assert it. The length you **submitted** (whitespace-stripped) is `meta.sequence_length`. (`data.input.sequence_length` was removed at contract revision 13.)

### API reference

**Python client**

```python
# client = Client(api_key=os.environ["GI_API_KEY"]), as in Promoter
listing = client.list_models("expression")
model = listing["default_model"]   # resolve once per run, pass on every call
# F = the largest recommended flank, never below 4,599, with 2F within the 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)
r = client.predict(
    "expression",
    sequence=locus,            # 2F bp, gene-sense strand, TSS at offset F
    model=model,
    tss_index=flank,           # 0-based, into the whitespace-stripped sequence
    options={"description": "K562 cells"},
)
print(r["data"]["prediction"]["expression"])                  # log(TPM+1)
print(r["meta"]["task_specific_counts"]["scored_window"])     # log it; width depends on the model
```

**MCP**

Ask the [MCP server](mcp.md): *"Predict HBB expression in K562 cells."*

### Applications

Use it to estimate the transcriptional output of a locus or a designed promoter under a stated cellular context, and as the objective in an expression-maximising or expression-matching design loop.

### Usage tips

- **9,198 bp is a floor, not a target.** Submit at least 9,198 bp with the TSS at least 4,599 bp from each end, and more where you can. Follow `default_model` (or the model your user named) and never hard-code a window, flank or model id: take `F` as the largest `bio_spec.recommended_flank_bp` in the [model list](#model-selection) and send `2F` bp with the TSS at offset `F` and `tss_index = F`. On the minus strand fetch `(tss − F, tss + F]` (0-based) and reverse-complement it; reverse-complementing `[tss − F, tss + F)` leaves the TSS one base off. Under-length input is rejected, never padded, and so is a window dominated by `N` or repeats.
- **`description` is conditioning text, not a label.** It is fed to the model, so rewording it changes the prediction. Hold it fixed across runs you intend to compare.
- **An in-range but wrong `tss_index` still scores** — a different window, with a confident `200`. Read the applied window back from `meta.task_specific_counts.scored_window` rather than assuming the request's.
- **The sequence is never reverse-complemented.** Submit minus-strand genes in transcript orientation.

## Annotation

Gene finding over long DNA: detects transcripts and returns their intervals. The `g0-annotation` model (multispecies) predicts transcript **boundaries**, transcript **type** (mRNA / lnc_RNA), and full internal **exon/intron/CDS structure**, and emits a browser-ready `GFF3` track. Submit the **plus-strand** genomic region; the model finds transcripts on both strands. This is the longest-running task, and the only predict operation with a hard sync cap: above **200,000 bp** a synchronous JSON request is refused with `413 sync_too_large`, `details` = `{sequence_length, threshold}` — resend the identical body with `Prefer: respond-async`. Below that, sync works at any accepted length, but prefer async above ~30,000 bp (see [Asynchronous jobs](rest-api.md#asynchronous-jobs)). The cap is on JSON delivery only: `?format=bed` and `?format=gff3` are served synchronously at any accepted length, so size those calls against the 300 s proxy timeout yourself.

- `sequence`: the highest floor of the five non-expression tasks (bounds in the table above); the gene finder cannot work below its own window.
- `model` (optional, top-level): `g0-annotation` — the only annotation model, selected by default.
- `options.shift_coordinates: "UCSC"`: rebase output coordinates from a UCSC-style `sequence_name` (e.g. `chr8:127,680,000-127,800,000`). Note that the header goes in `sequence_name` (≤ 128 chars), not in the `sequence` — a `>` line inside the sequence fails the alphabet check.
- `options.reverse_complement` (bool, default `true`): average each locus with its reverse complement, improving minus-strand recall. Set `false` for a faster single-pass run.

`AnnotationOptions` accepts those two keys and nothing else.

Output: transcript intervals, each carrying its own strand (`+`/`-`) plus `tss_position` and `polya_position`. Each transcript additionally carries `transcript_type`, `transcript_type_score`, and 0-based half-open `exons`/`introns`/`cds` arrays, and `data.formats.gff3` holds a full `GFF3` track (also via `Accept: text/x-gff3`). `BED` is also available — `?format=json|bed|gff3`. See [output formats](rest-api.md#output-formats).

### API reference

**Python client**

```python
# client = Client(api_key=os.environ["GI_API_KEY"]), as in Promoter
# Prefer: respond-async, then poll GET /v1/tasks/jobs/{job_id}
job_id = client.submit_async("annotation", sequence=region, options={"shift_coordinates": "UCSC"},
                             sequence_name="chr8:127,680,000-127,800,000")
result = client.wait_for_job(job_id)
transcripts = result["data"]["transcripts"]
```

**MCP**

Ask the [MCP server](mcp.md): *"Find the genes in chr8:127,680,000-127,800,000."*

### Applications

Use it to annotate assembled contigs or synthetic constructs where no reference annotation exists, and to supply TSS positions (`tss_position`) to [expression](#expression) when they are not known in advance.

### Usage tips

- **The floor is 1,000 bp**, the highest of the five non-expression tasks.
- **One submission finds both strands.** Submit the plus-strand region; each transcript carries its own `strand`, relative to the sequence as submitted.
- **This is the slowest task, and the only predict operation with a sync cap.** Above 200,000 bp a synchronous JSON request is `413 sync_too_large`; send `Prefer: respond-async` and poll. Below the cap async is a latency preference, not a requirement.
- **`reverse_complement: false` is a faster single pass**, at some cost in minus-strand recall.

## Find genes and predict expression

`POST /v1/workflows/find-genes-and-predict-expression` — a composite that chains the two tasks server-side: annotate the region, cut a window around each found gene's TSS, and score 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. One round trip, no intermediate results shuttled back to your client, and no `tss_index` to compute.

It is a published operation on the same bearer key as everything else; nothing needs enabling.

- `sequence`: **1,000–500,000 bp** — annotation's floor, since the workflow cannot find a gene in less sequence than the gene finder looks at.
- `options` (**required**), and `options.description` (**required** — the expression stage is conditioned on it; missing or empty is `422 validation_failed`, "options.description is required (cell type / assay context)"). Also accepts `annotation_model`, `expression_model`, `shift_coordinates`. Closed, like every other `options`.
- Above **50,000 bp** synchronous delivery is refused with `413 sync_too_large`, `details` = `{sequence_length, threshold}`. Resend the identical body with `Prefer: respond-async` and poll `GET /v1/tasks/jobs/{job_id}` (see [Asynchronous jobs](rest-api.md#asynchronous-jobs)). This is the lower of the contract's two sync caps; [annotation](#annotation) carries the other, at 200,000 bp.

Output: `data.expression_predictions[]`, one entry per gene found; a gene the workflow could not score carries a `skip_reason`. `meta.task_specific_counts` = `{genes_found, genes_predicted, genes_skipped}`, with `genes_predicted + genes_skipped == genes_found`. Unlike the direct expression endpoint, the workflow pads a TSS window with `N` up to half its width rather than dropping a gene near the edge of your region. JSON only.

### API reference

**Python client**

```python
# client = Client(api_key=os.environ["GI_API_KEY"]), as in Promoter
accepted = client.find_genes_and_predict_expression(region, description="K562 cells", async_=True)
result = client.wait_for_job(accepted["data"]["job_id"])
for gene in result["data"]["expression_predictions"]:
    print(gene.get("skip_reason") or gene["expression"])
```

**MCP**

Ask the [MCP server](mcp.md): *"Find the genes in chr8:127,680,000-127,800,000 and predict each one's expression in K562."*

### Applications

Use it when the TSS positions are not known in advance: it annotates and scores a whole locus in one call, rather than you chaining [annotation](#annotation) into [expression](#expression) yourself.

### Usage tips

- **The floor is 1,000 bp** (annotation's), and the ceiling is this endpoint's own 500,000 bp, which is not the expression model's window.
- **Above 50,000 bp, go async.** A synchronous request is `413 sync_too_large`; resend the identical body with `Prefer: respond-async`. This is the lower of the contract's two sync caps.
- **A gene near the edge of your region is still scored:** its window is padded with `N` rather than the gene dropped. A gene the workflow could not score carries `skip_reason`, and `genes_predicted + genes_skipped == genes_found`.
- **JSON only.** Unlike the predict operations it has no `?format=` parameter.

## Genomic variant interpretation

`POST /v1/workflows/genomic-variant-interpretation` — the one operation that does not take a DNA `sequence`. You give it the `s3://` URI of a VCF in your own object storage; it scores the variants that fall near a transcription start site and writes an **annotated copy beside it, named after it**. The JSON you get is a receipt: where the annotated file is, and how the run went. You never parse a VCF to drive the workflow, and the annotation format inside it can change without breaking anything you built.

> This workflow is not finished. The contract below is real and will not change silently, but the per-tissue values in an annotated file are **not final and are not yet to be read as results**. A run is model output when, and only when, the response carries `meta.model` and the annotated VCF carries a `##GEXModel` header line — until both appear, treat the numbers as format rather than findings. It also needs a grant on your bucket before it will run for you, so [talk to us](resources/support.md) rather than integrating against it unannounced.

**Asynchronous only.** A whole-exome case runs for hours, well past any proxy timeout, so `Prefer: respond-async` is **required**; a request without it is `400 bad_request`. There is no callback — submit, then poll `GET /v1/tasks/jobs/{job_id}` (see [Asynchronous jobs](rest-api.md#asynchronous-jobs)).

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

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

**You name the VCF itself**, not a folder — nothing else in the prefix is listed or read, so a manifest, an index, or a second VCF beside it are all irrelevant, and the format of your own case metadata stays yours. Everything the analysis needs arrives as typed `options`. The key must end in `.vcf`, `.vcf.gz` or `.vcf.bgz`; uncompressed, `bgzip`- and plain `gzip`-compressed files are all accepted and you never need to recompress anything.

**The annotated copy is written beside the input, named after it and after the job**: `sample.vcf.gz` becomes `sample.annotated.<job>.vcf.gz` in the same prefix, where `<job>` is the first eight characters of the `job_id`. **Read the exact name from `data.output.uri` rather than constructing it.**

The job is in the name because the input alone does not identify a run: analysing one VCF against two different panels produces two different results, and under an input-only name the second would overwrite the first while the first receipt — retained for 24 hours — still pointed at it. Two VCFs in one folder are likewise two independent cases. A resubmission resolves to the same job and so rewrites the same object rather than accumulating copies.

Grant the service `s3:GetObject` on the object and `s3:PutObject` on its prefix. **No `s3:ListBucket` is needed**, because nothing searches.

- `input.type`: `s3_object` today. The field is a discriminated union so another kind of input — an inline VCF, a presigned URL — can be added without breaking this one.
- `options.genome_build` (**required**): `GRCh37`. A VCF header declares its assembly only sometimes, and guessing misplaces every window.
- `options.window`: bp from a start site within which a variant is scored. Default `5000`, range 1–100,000.
- `options.tissues`: cell-type contexts, one scoring run each. Default `["heart", "liver", "brain"]`, 1–16 entries. The expression model is conditioned on these strings and the exact wording moves the result.
- `options.genes`: restrict scoring to a panel. Accepts HGNC symbols (`BRCA1`) or Ensembl gene ids with or without a version (`ENSG00000141510`, `ENSG00000141510.17`); symbols are case-insensitive, up to 1,000 entries. Omit it to score every gene near a variant.
- `client_ref`: an opaque string of yours, ≤128 characters, echoed back. It carries your case identity so this API never has to model a case.

**The request is validated before the job is accepted.** All of these are `400 bad_request` on the POST, so a typo costs a second rather than a poll cycle through a job that fails minutes later: a URI that is not `s3://bucket/key`; one naming a prefix rather than an object; a key not ending `.vcf`, `.vcf.gz` or `.vcf.bgz`; an object this service cannot read (check the `s3:GetObject` grant); an empty VCF, or one over 2 GiB; a `genes` identifier matching no gene; a `genome_build` with no published start-site catalogue. Anything that goes wrong after acceptance is a job failure visible on the poll — including a file that turns out to be unreadable, or to already carry `GEX_*` fields, neither of which can be known until it is opened.

**A panel is the difference between a long case and a short one.** Unrestricted, a whole exome scores tens of thousands of allele-to-start-site pairs; restricted to a handful of genes it scores a handful. Records near a start site that is *not* in your panel are still written to the output, flagged `NO_REQUESTED_GENE_IN_WINDOW` — deliberately distinct from `OUTSIDE_TSS_WINDOW`, because "you did not ask about this gene" and "there was no start site here" are different answers, and a narrow panel should not read as a coverage gap. The resolved panel is echoed in `meta.options_applied.genes` and recorded in the output VCF's `##GEXRequestedGenes` header, so a restricted run cannot later be mistaken for a whole-genome one. An identifier matching no MANE Select gene is a `400` naming every unresolved one — a panel silently missing a gene would return a result that looks complete and is not.

**Submitting the same case twice gives you the same job.** The `job_id` is derived from your key, `input.uri`, the input object's ETag, `client_ref` and `options`, so a retry after an ambiguous failure — or a restarted integration — resolves to the run you already have rather than starting a second hours-long pass over the same object. There is no idempotency header to send. Two consequences worth knowing: a **failed** job re-runs on resubmission, and to deliberately re-run a case that **succeeded**, change `client_ref`. Replacing the VCF under an unchanged path also yields a new job, since its ETag is part of the derivation.

Accepted submissions return `202` with `data = {job_id, status: "accepted", links}`, plus `X-Job-Id` and `Content-Location`. On completion the poll returns:

```json
{
  "data": {
    "task": "genomic_variant_interpretation",
    "client_ref": "CASE-00123",
    "status": "ok",
    "output": { "type": "s3_object", "uri": "s3://your-bucket/cases/CASE-00123/sample.annotated.3eaecf69.vcf.gz" },
    "counts": {
      "variants_in": 73771,
      "annotated": 12721,
      "not_run": 61050,
      "not_run_reasons": { "OUTSIDE_TSS_WINDOW": 61050 },
      "annotated_alleles": 12725,
      "associations": 14698,
      "skipped_alleles": 10
    }
  },
  "meta": {
    "task": "genomic_variant_interpretation",
    "genome_build": "GRCh37",
    "job_id": "…", "request_id": "…",
    "input": { "type": "s3_object", "uri": "s3://your-bucket/cases/CASE-00123/sample.vcf.gz" },
    "gene_annotation": {
      "build": "v50lift37",
      "md5": "bed31c2cead98e9e1989ef7943fb3206",
      "transcripts": "MANE Select",
      "start_sites": 19153
    },
    "options_applied": { "window": 5000, "tissues": ["heart", "liver", "brain"], "genes": null }
  }
}
```

**Every input record reaches the output**, scored or not, so `annotated + not_run == variants_in` and you can diff input against output record for record. A record that could not be scored is flagged in place with `GEX_NOT_RUN` and a reason in `GEX_NOT_RUN_REASON` — `OUTSIDE_TSS_WINDOW`, `NO_TSS_ON_CONTIG`, `NO_SUPPORTED_ALT`, or `NO_REQUESTED_GENE_IN_WINDOW` when you sent a panel — rather than dropped. The same four appear in `counts.not_run_reasons`, which sums to `not_run`. A record with some usable and some unusable ALT alleles — a symbolic allele beside a plain one, say — lists the unusable ones in `GEX_SKIPPED_ALT` and is still annotated for the rest, so a record can carry both keys. Scored records carry `GEX_ANN`, one VEP-style entry per supported ALT allele and nearby transcript: `Allele|Gene|Gene_ID|Transcript_ID|TSS|Strand|TSS_distance|` then one log2 fold-change field per requested tissue. Distances are strand-aware, negative upstream, and the output header records the convention along with the window and the GENCODE release the start sites came from — and the model, once one is named. `meta.input` echoes the object you named, so a stored receipt names both what was read and what was written. `counts.associations` is the **unit of scoring work** — allele-to-start-site pairs, not records — so it is the number to reason about when sizing a case, and `meta.gene_annotation` is the machine-readable form of that provenance.

**`meta.model` tells you whether these are model output.** While this workflow is under development the field is absent, as is the annotated VCF's `##GEXModel` header line — the run is not attributed to a model that did not produce the values. Both appear, naming the model, once it is. Everything around them is real today: your file is read, every record is matched against GENCODE v50lift37 MANE Select start sites, every record is written back, and the counts are computed from it.

Submitting a VCF that already carries `GEX_*` fields — an annotated copy from a previous run, for instance — is a `400`: submit the original. Results are retained for **24 hours** and then return `410`. The annotated file in your own bucket is not on that clock. A job does not survive a deployment of this service: in-flight work is lost and the poll reports `503` with a message to resubmit, so retries belong to you.

This workflow is REST-only — it is not exposed as an [MCP](mcp.md) tool.

### API reference

**Python client**

The kit client does not wrap this workflow. Post it with `requests` and poll with the client:

```python
import os, requests
from gi_client import Client

key = os.environ["GI_API_KEY"]
r = requests.post(
    "https://api.genomicintelligence.ai/v1/workflows/genomic-variant-interpretation",
    headers={"Authorization": f"Bearer {key}", "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"},
    },
    timeout=60,
)
receipt = Client(api_key=key).wait_for_job(r.json()["data"]["job_id"], max_wait=6 * 3600)
print(receipt["data"]["output"]["uri"])
```

**MCP**

This workflow is REST-only; it is not exposed as an [MCP](mcp.md) tool.

### Applications

Use it to score the variants in a case's VCF that fall near a transcription start site, across the tissues you name, and get the annotated VCF back in your own bucket beside the input, where the rest of your pipeline already reads.

### Usage tips

- **Async only.** `Prefer: respond-async` is required; without it the request is `400 bad_request`.
- **Send a gene panel when you have one.** Restricted to a handful of genes a case scores a handful of allele-to-start-site pairs instead of tens of thousands.
- **The same case twice is the same job.** To deliberately re-run a case that succeeded, change `client_ref`.
- **`tissues` are conditioning text.** The exact wording moves the prediction, as with expression's `description`.
- **Not results yet.** Until the response carries `meta.model` and the VCF a `##GEXModel` header, treat the values as format rather than findings.

---

Next: [Limits](reference/limits.md) for caps and latency, or the [REST API guide](rest-api.md) for the call walkthrough.
