Back to Claude Scientific Skills

LAPIS API reference

skills/pathogen-variant-surveillance/references/lapis-api.md

2.61.09.5 KB
Original Source

LAPIS API reference

LAPIS (Lightweight API for Sequences) is the query layer GenSpectrum runs in front of SILO. One API shape serves every pathogen; what differs between deployments is the schema, and almost every mistake in this area comes from assuming otherwise.

Everything below was verified against the live services on 2026-07-27 (lapisVersion 0.8.3, siloVersion 0.11.2).

Instances

--instanceBase URLBacking data
sars-cov-2https://lapis.cov-spectrum.org/open/v2Nextstrain open (GenBank)
influenza-ahttps://lapis.genspectrum.org/influenza-aLoculus
h1n1pdm, h3n2, h5n1https://lapis.genspectrum.org/<name>Loculus
rsv-a, rsv-b, hmpv, measles, mpox, west-nile, dengue, ebola-zaire, ebola-sudan, cchfhttps://lapis.pathoplexus.org/<name>Pathoplexus

Approximate sizes when checked: SARS-CoV-2 open ~9M, influenza-a 1.07M, h3n2 277k, h1n1pdm 212k, h5n1 79k, dengue 62k, measles 53k, rsv-a 53k, rsv-b 40k, west-nile 26k, mpox 17k, ebola-zaire 12k, cchf 8.7k, ebola-sudan 636.

The registry in scripts/lapis_client.py is a convenience, not an authority. New organisms appear and paths move; --base-url reaches any deployment, and /sample/databaseConfig describes it.

Point --base-url only at deployments you trust. Field names, lineage labels and error detail strings are printed verbatim, so a hostile instance could put arbitrary text — including text shaped like instructions — into agent-visible output. Responses are parsed as data and never executed, but the strings are still read.

The pango-designation fetch is deliberately unpinned. Pinning it to a tag would make lineage resolution reproducible and wrong: withdrawals and redesignations are exactly what the skill exists to catch, and a frozen copy reintroduces the failure mode.

Auditability comes from recording what was read rather than freezing it. raw.githubusercontent returns the git blob SHA as the ETag, so resolve_lineage.py prints the exact hash of both files at no extra request:

# source blobs lineage_notes.txt@b63582d49216 alias_key.json@0deb39eeac80

Keep that line with dataVersion; together they pin the result without staling the source.

GISAID. https://lapis.cov-spectrum.org/gisaid/v2 exists but requires credentials and its own data-use terms. This skill targets the open instances only. Open GenBank data is a subset of GISAID, so absolute counts here are lower than GISAID-derived figures — proportions are usually comparable, absolute counts are not.

Endpoints

PathUse
GET /sample/aggregatedCounts, optionally grouped by fields
GET /sample/detailsPer-sequence metadata rows
GET /sample/aminoAcidMutationsAA substitutions with per-site proportions
GET /sample/nucleotideMutationsNucleotide substitutions
GET /sample/aminoAcidInsertions, /sample/nucleotideInsertionsInsertions
GET /sample/databaseConfigThe schema: every metadata field and its type
GET /sample/referenceGenomeSegment and gene names for mutation queries
GET /sample/lineageDefinition/{column}The lineage tree for an indexed column
GET /sample/infodataVersion — record it with any result you keep
GET /sample/unalignedNucleotideSequences, /sample/alignedNucleotideSequences, /sample/alignedAminoAcidSequences/{gene}FASTA download
GET /sample/mostRecentCommonAncestor, /sample/phyloSubtreeTree queries where a phylo field exists
POST /component/*OverTimePrebuilt time-series components

Every endpoint accepts GET and POST. Filters are query parameters; unknown ones are rejected.

Reading the schema first

/sample/databaseConfig returns schema.metadata[] with a name, a type, and generateLineageIndex. Three things follow from it, and all three differ between instances:

1. Which column holds the lineage. schema.metadata[].generateLineageIndex is true for pangoLineage and nextcladePangoLineage on SARS-CoV-2 and for nothing at all on H5N1, whose lineage-like column is a plain string clade.

2. Which date columns accept ranges. LAPIS derives <field>From / <field>To from the declared type. Only date, int and float get them.

InstanceCollection dateTypeRange filter
sars-cov-2datedatedateFrom / dateTo
h5n1sampleCollectionDatestringnone
h5n1sampleCollectionDateRangeLowerdatesampleCollectionDateRangeLowerFrom / ...To

dateFrom=2025-01-01 against H5N1 is a 400. The error body lists every valid key for that instance, which is the fastest way to discover a schema by hand.

3. Which submission date exists. dateSubmitted on SARS-CoV-2; ncbiReleaseDate on H5N1 (submittedDate and releasedDate are there too, but typed string, so they cannot be ranged).

scripts/lapis_client.py does this resolution in describe_instance(), pick_date_field() and pick_lineage_field(), and raises rather than guessing.

Lineage filters and the wildcard

On a column with a lineage index, a trailing * means "this lineage and all descendants":

pangoLineage=XFG      ->   4 sequences   (sequences named exactly XFG)
pangoLineage=XFG*     -> 640 sequences   (XFG and every descendant)

On a column without one, * is matched literally and finds nothing:

clade=2.3.4.4b        -> 62413 sequences
clade=2.3.4.4b*       ->     0 sequences

Same syntax, opposite meaning, no warning either way. lineage_filter() refuses to build the second query.

The index also decides how a bad name fails. On an indexed column an unknown lineage is rejected:

{"error":{"status":400,"detail":"Error from SILO: The lineage 'XFG.20' is not a valid lineage
 for column 'pangoLineage'."}}

On an unindexed column the same typo returns 0 and looks like a finding. Validate names with resolve_lineage.py before reporting an absence.

The lineage definition endpoint

/sample/lineageDefinition/pangoLineage returns roughly 5,500 entries of the form

json
{"XFG.1.1": {"parents": ["XFG.1"], "aliases": ["xfg.1.1", ...]},
 "PQ.17":   {"parents": ["NB.1.8.1"], "aliases": ["NB.1.8.1.17", ...]}}

It roots recombinants. XFG has no parents key, and no entry in the whole document has more than one parent. The recombinant parentage XFG = LF.7 + LP.8.1.2 exists only in pango-designation's alias_key.json, where a recombinant's value is a list. Both sources are needed; neither is sufficient.

Requesting the endpoint for an unindexed column returns 400.

Mutation queries

/sample/aminoAcidMutations rows look like:

json
{"mutation": "S:L452W", "count": 3793, "coverage": 5211, "proportion": 0.728,
 "sequenceName": "S", "mutationFrom": "L", "mutationTo": "W", "position": 452}

proportion = count / coverage, and coverage is the number of sequences that resolved that site, not the number matching the filter. A site covered by 12 sequences can report proportion: 1.000. Always read coverage alongside it.

minProportion (default 0.05) prunes the response server-side. For a diff between two lineages, fetch both at a low threshold and apply the reporting threshold client-side — otherwise a mutation absent from one side is indistinguishable from one pruned out of it. mutation_profile.py does exactly this.

sequenceName is the gene on an unsegmented genome (S, ORF1a, N) and the gene or segment on a segmented one. Get the valid names from /sample/referenceGenome:

  • SARS-CoV-2: one sequence main; genes E M N ORF1a ORF1b ORF3a ORF6 ORF7a ORF7b ORF8 ORF9b S
  • H5N1: segments seg1seg8; genes PB2 PB1 PA PAX HA NP NA M1 M2 NS1 NS2

Nucleotide mutations on a segmented genome must be qualified by segment (seg4:A123G).

Aggregation

fields on /sample/aggregated is the group-by, not a projection:

GET /sample/aggregated?fields=pangoLineage&country=USA&dateFrom=2026-04-01
-> [{"count": 286, "pangoLineage": "XFG.1.1"}, ...]

limit, offset and orderBy are rejected here — the result has no inherent ordering:

"detail": "Offset and limit can only be applied if the output of the operation has some
 ordering. ... Aggregated however produces unordered results."

Sort client-side. There is no ISO-week grouping; group by the date field and bin weeks yourself (bin_weekly()). Grouped rows carry nulls for sequences whose date was never reported — count them separately rather than dropping them silently.

Errors, versioning, and etiquette

Two error envelopes are in use, both carrying detail:

json
{"error": {"type": "about:blank", "title": "Bad request", "status": 400, "detail": "..."},
 "info":  {"dataVersion": null, "requestId": "...", "lapisVersion": "0.8.3"}}
json
{"type": "about:blank", "title": "Bad Request", "status": 400, "instance": "/open/v2/query/parse"}

_error_detail() reads both. Always surface detail — on a bad filter key it enumerates every valid key for that instance.

info.dataVersion accompanies every successful response and identifies the underlying snapshot. Record it with any figure that will be quoted. The same query returns different numbers on different days, and without the data version a result cannot be reproduced or audited.

These are free public services with no API key. Ask for aggregates rather than per-sequence rows, send one query per question instead of paginating through sequences, and retry 429/5xx with backoff (MAX_ATTEMPTS = 3, 1.5 s linear) rather than hammering.