Natural-language Q&A over a US coordinate (LLM-backed)
curl --request POST \
--url https://api.example.com/v1/ask \
--header 'Content-Type: application/json' \
--data '
{
"question": "<string>",
"lat": 123,
"lng": 123,
"address": "<string>",
"include_trace": false
}
'import requests
url = "https://api.example.com/v1/ask"
payload = {
"question": "<string>",
"lat": 123,
"lng": 123,
"address": "<string>",
"include_trace": False
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
question: '<string>',
lat: 123,
lng: 123,
address: '<string>',
include_trace: false
})
};
fetch('https://api.example.com/v1/ask', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/v1/ask",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'question' => '<string>',
'lat' => 123,
'lng' => 123,
'address' => '<string>',
'include_trace' => false
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.example.com/v1/ask"
payload := strings.NewReader("{\n \"question\": \"<string>\",\n \"lat\": 123,\n \"lng\": 123,\n \"address\": \"<string>\",\n \"include_trace\": false\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.example.com/v1/ask")
.header("Content-Type", "application/json")
.body("{\n \"question\": \"<string>\",\n \"lat\": 123,\n \"lng\": 123,\n \"address\": \"<string>\",\n \"include_trace\": false\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.example.com/v1/ask")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"question\": \"<string>\",\n \"lat\": 123,\n \"lng\": 123,\n \"address\": \"<string>\",\n \"include_trace\": false\n}"
response = http.request(request)
puts response.read_body{
"detail": [
{
"loc": [
"<string>"
],
"msg": "<string>",
"type": "<string>",
"input": "<unknown>",
"ctx": {}
}
]
}Ask
POST /v1/ask
Natural-language Q&A over US coordinates with per-citation provenance.
POST
/
v1
/
ask
Natural-language Q&A over a US coordinate (LLM-backed)
curl --request POST \
--url https://api.example.com/v1/ask \
--header 'Content-Type: application/json' \
--data '
{
"question": "<string>",
"lat": 123,
"lng": 123,
"address": "<string>",
"include_trace": false
}
'import requests
url = "https://api.example.com/v1/ask"
payload = {
"question": "<string>",
"lat": 123,
"lng": 123,
"address": "<string>",
"include_trace": False
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
question: '<string>',
lat: 123,
lng: 123,
address: '<string>',
include_trace: false
})
};
fetch('https://api.example.com/v1/ask', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/v1/ask",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'question' => '<string>',
'lat' => 123,
'lng' => 123,
'address' => '<string>',
'include_trace' => false
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.example.com/v1/ask"
payload := strings.NewReader("{\n \"question\": \"<string>\",\n \"lat\": 123,\n \"lng\": 123,\n \"address\": \"<string>\",\n \"include_trace\": false\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.example.com/v1/ask")
.header("Content-Type", "application/json")
.body("{\n \"question\": \"<string>\",\n \"lat\": 123,\n \"lng\": 123,\n \"address\": \"<string>\",\n \"include_trace\": false\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.example.com/v1/ask")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"question\": \"<string>\",\n \"lat\": 123,\n \"lng\": 123,\n \"address\": \"<string>\",\n \"include_trace\": false\n}"
response = http.request(request)
puts response.read_body{
"detail": [
{
"loc": [
"<string>"
],
"msg": "<string>",
"type": "<string>",
"input": "<unknown>",
"ctx": {}
}
]
}When to use this
Use/v1/ask when the caller has a question phrased in natural language
(“is this in a flood zone?”, “what’s the wildfire risk here?”) and the
right answer depends on combining one or more fields with light
interpretation. The endpoint runs a two-model pipeline that picks fields
from the catalog, fetches them, and synthesizes the answer with citations.
Use /v1/fetch instead when the caller already
knows which fields they want, or when you are building a deterministic
workflow that should not pay LLM latency on every call.
Asking about an address
/v1/ask takes a location — either lat + lng or an address, never
both. Sending both, or neither, is a 422.
curl -s https://api.mireye.com/v1/ask \
-H "Authorization: Bearer $MIREYE_API_TOKEN" \
-H 'content-type: application/json' \
-d '{"address": "350 5th Ave, New York, NY 10118",
"question": "Is this in a flood zone?"}' | jq
geocode block describing how the coordinate was derived
(same shape as /v1/geocode).
If the address could only be estimated — parcel_grade: false, meaning a
street-centerline interpolation rather than a rooftop match — the caveat is
appended to the answer text itself, not just tucked into the provenance block:
…The site is not within a FEMA Special Flood Hazard Area. Note: this location was estimated from the street address rather than matched to a rooftop, so it can be off by up to ~2,872 m in rural areas. Confirm the address before relying on parcel-specific conclusions.That is deliberate. An LLM answer reads as confident prose and often gets relayed onward by an agent without the surrounding JSON, so the sentence a human actually reads has to carry the uncertainty. The caveat is added deterministically by the API — it is not something the model can forget. Address failures use the same codes as
/v1/geocode
(address_not_found, address_too_coarse, …). See
Errors.
How it works
- Planner (Claude Haiku 4.5) — receives the question, the
coordinate, and the field catalog rendered into the system prompt.
Returns a structured tool call naming the catalog fields it wants
fetched (capped at 15 per question — note that preset expansions
larger than the cap, e.g.
site_selectionordata_center_siting, are truncated to the first 15 fields of the preset). The planner system prompt is prompt-cached (catalog-sized), so steady-state input cost is ~90% below the uncached path. - Fetch (deterministic, parallel) — the orchestrator groups the
selected fields by layer and dispatches one fan-out per layer. Each
layer returns
Field_[T]wrappers carrying value + provenance. - Synthesizer (Claude Sonnet 4.6) — receives the question and the rendered values, returns a structured tool call with the prose answer and the list of fields it actually used.
- Citation extraction (deterministic) — walks
fields_used, groups them by source, and emits one citation per source.
Worked example: wildfire risk in rural Minnesota
Aitkin County, Minnesota — a rural parcel of open herbaceous cover and wetlands. A small-business insurer wants a wildfire underwriting read.curl -s https://api.mireye.com/v1/ask \
-H "Authorization: Bearer $MIREYE_API_TOKEN" \
-H 'content-type: application/json' \
-d '{
"lat": 46.6,
"lng": -93.7,
"question": "What is the wildfire risk at this location and what fuel is on the ground?"
}' | jq
{
"lat": 46.6,
"lng": -93.7,
"question": "What is the wildfire risk at this location and what fuel is on the ground?",
"answered_at": "2026-06-12T07:31:33.971740+00:00",
"answer": "Wildfire risk at this location (46.6, -93.7) is LOW. The land cover is classified as Grass/Forb/Herb with 0.0% tree canopy cover, meaning there is no forested fuel structure present — the primary surface fuel is herbaceous/grassland material. The terrain is essentially flat at 0.05 degrees slope, which provides no topographic amplification of fire spread. Current vegetation greenness is low (NDVI 0.18), suggesting sparse or stressed herbaceous cover, which could carry fire under dry conditions, but the absence of woody fuels and dense canopy significantly limits fire intensity and continuity.\n\nOne notable concern is the severe 5-year NDVI decline of -0.49, indicating substantial vegetation loss over the past five years. This level of change warrants investigation into whether drought, land conversion, or prior disturbance has left dead or dry fine fuels on the ground, which could temporarily elevate short-range fire risk. However, without tree canopy or shrub structure, this location does not present the elevated wildfire risk profile associated with forested or WUI (Wildland-Urban Interface) settings.",
"confidence": "medium",
"citations": [
{
"source": "COPERNICUS_S2_SR_HARMONIZED",
"source_url": "https://developers.google.com/earth-engine/datasets/catalog/COPERNICUS_S2_SR_HARMONIZED",
"fields": [
"ndvi_change_5y",
"ndvi_current"
],
"fetched_at": "2026-06-12T07:28:10.065940+00:00",
"confidence": "high"
},
{
"source": "USFS_LCMS",
"source_url": "https://developers.google.com/earth-engine/datasets/catalog/USFS_GTAC_LCMS_v2024-10",
"fields": [
"lcms_class"
],
"fetched_at": "2026-06-12T07:28:08.500290+00:00",
"confidence": "high"
},
{
"source": "USGS_3DEP_COG",
"source_url": "s3://prd-tnm/StagedProducts/Elevation/13/TIFF/current/n47w094/USGS_13_n47w094.tif",
"fields": [
"slope_degrees"
],
"fetched_at": "2026-06-12T07:31:39.962998+00:00",
"confidence": "medium"
},
{
"source": "USGS_EPQS",
"source_url": "https://epqs.nationalmap.gov/v1/json?x=-93.7&y=46.6&wkid=4326&units=Meters",
"fields": [
"elevation"
],
"fetched_at": "2026-06-12T07:31:40.667956+00:00",
"confidence": "high"
},
{
"source": "USGS_NLCD_TCC_V2023_5",
"source_url": "https://developers.google.com/earth-engine/datasets/catalog/USGS_NLCD_RELEASES_2023_REL_TCC_v2023-5",
"fields": [
"tree_canopy_pct"
],
"fetched_at": "2026-06-12T07:28:10.065891+00:00",
"confidence": "high"
}
],
"fields_used": [
"elevation",
"lcms_class",
"ndvi_change_5y",
"ndvi_current",
"slope_degrees",
"tree_canopy_pct"
],
"resolved_location": {"lat": 46.6, "lng": -93.7, "source": "coordinate"}
}
resolved_location — the point it actually answered
about, with source: "coordinate" (as-sent) or "address" (geocoded, with
the full geocode block alongside). One uniform key across /v1/fetch,
/v1/ask and the streaming final frame, so a wrong-place answer is
catchable without per-endpoint knowledge.
The planner here expanded the wildfire_underwrite preset
(preset_expanded in the trace) — exactly its six fields. Its visible
reasoning weighed adding land_use_class and cdl_class for the
“fuel on the ground” angle and judged the preset’s signals sufficient.
Note the medium answer confidence: all six fields returned (so no
calibration downgrade fired), but the slope citation carries medium
source confidence and the answer itself flags the −0.49 five-year NDVI
decline as the open question — the synthesizer’s self-report is bounded
accordingly.
Confidence calibration
confidence is a three-bucket string: high, medium, low. The
synthesizer self-reports a bucket, then a deterministic calibration step
bounds its optimism:
| Bucket | Meaning |
|---|---|
high | All planner-selected fields fetched cleanly; sources are direct (USGS, NOAA, USFS) and current. |
medium | One or more selected fields downgraded (e.g., NDVI is satellite-derived) OR some fields nullified. |
low | Substantial nulls/failures among selected fields; synthesizer answered with partial data. |
confidence drops
one bucket (high → medium, medium → low) regardless of what the
synthesizer self-reported.
Latency and cost
/v1/ask is LLM-backed and much slower than /v1/fetch — it runs a serial
plan → fetch → synthesize pipeline. Set your client timeout to at least 120
seconds (or use streaming). A 30 s timeout will
intermittently abort otherwise-successful requests — and the request keeps
running, and billing, on the server after your client gives up.
| stage | typical | tail | hard bound |
|---|---|---|---|
| end-to-end | ~6–20 s | up to ~90 s | 110 s, then 504 ask_timeout |
- Steady-state warm path: ~6–15 s — single-field questions at the low end, multi-field synthesis at the high end. Planner 2–6 s, fetch fan-out sub-second when the layer cache is warm, synthesizer 3–8 s.
- Tail latency comes from the fetch stage, not the models. A question that selects a slow source lets that source’s own budget dominate: Earth Engine fields allow up to 60 s, road/building fields 30–35 s. One such field can push a single request well past 30 s while the models stay fast — expected, not a failure.
- Deadline: requests are bounded at 110 seconds end-to-end — past that
you get a structured
504 ask_timeout(carrying aRetry-Afterheader) instead of a hang. Per-stage timeouts inside the pipeline: planner 20 s, synthesizer 30 s, one SDK retry. - Cold start: the hosted deploy keeps machines running, but the first
requests after a deploy can be slower while geospatial sources warm in the
background (
/readyzreports warm state). Self-hosted instances pay this on every boot. - Cache savings: the planner system prompt is catalog-sized; cache reads land at a ~90% input-token discount. Per-question incremental cost is dominated by synthesizer output (~500 tokens at Sonnet rates).
Streaming responses
For latency-sensitive or interactive clients,POST /v1/ask/stream streams the
answer over Server-Sent Events
so you get first tokens in ~5–7 s (planner + fetch) instead of awaiting the
whole synthesis. It takes the same request body as /v1/ask and enforces the
same 110 s deadline and error codes.
Frames arrive as named SSE events:
| event | payload | count |
|---|---|---|
delta | {"text": "..."} — the next chunk of answer prose | zero or more |
final | the complete /v1/ask body (answer, confidence, citations, fields_used, optional trace) | exactly one, terminal |
error | {"error", "message", "retryable"} — a mid-stream failure | at most one, terminal |
curl -sN https://api.mireye.com/v1/ask/stream \
-H "Authorization: Bearer $MIREYE_API_TOKEN" \
-H 'content-type: application/json' \
-d '{"lat": 46.6, "lng": -93.7, "question": "Wildfire risk here?"}'
ask_busy) surface as a normal HTTP status; once the 200
body has started, a later failure can only arrive as a terminal error frame.
Consume the final frame for the authoritative body — the delta stream is a
progressive preview of the same prose.
Including the trace
Pass"include_trace": true to get planner-internal diagnostics:
"trace": {
"planner_model": "claude-haiku-4-5",
"synthesizer_model": "claude-sonnet-4-6",
"planner_reasoning": "The user is asking about wildfire risk and ground fuel at this location. This is a broad wildfire underwriting/risk assessment question that combines multiple factors: land cover/fuel type (lcms_class, tree_canopy_pct), vegetation health and change (ndvi_current, ndvi_change_5y), and terrain (slope_degrees, elevation). This matches the wildfire_underwrite preset directly. Additionally, for \"fuel on the ground\" context, land_use_class and cdl_class could add detail, but the preset already covers the primary fuel signals.",
"fields_requested": [
"lcms_class",
"tree_canopy_pct",
"ndvi_current",
"ndvi_change_5y",
"slope_degrees",
"elevation"
],
"preset_expanded": "wildfire_underwrite",
"latency_ms": {
"planner_ms": 5977,
"fetch_ms": 719,
"synth_ms": 7504,
"total_ms": 14200
},
"cache_creation_input_tokens": 431,
"cache_read_input_tokens": 11767
}
cache_read_input_tokens field is the load-bearing signal that the
planner system prompt cached correctly (11,767 tokens measured live
against the 0.5.0 catalog; the cached prompt scales with the catalog —
roughly 19 K tokens at 0.6.0, since the rendered field catalog dominates
the prompt).
First-ever calls show the same number under
cache_creation_input_tokens instead.
Errors
Beyond the common error codes,/v1/ask maps
LLM-upstream failures to structured codes — all with the
{"detail": {"error", "message", "retryable"}} shape:
error | HTTP | Meaning | Retryable |
|---|---|---|---|
ask_busy | 429 | The fleet-wide /v1/ask concurrency cap is saturated; no model work started. | yes |
ask_timeout | 504 | The request exceeded the 110 s end-to-end deadline. | yes |
ask_upstream_rate_limited | 429 | The LLM API rate-limited the call. | yes |
ask_upstream_unreachable | 502 | Could not reach the LLM API (connection error). | yes |
ask_upstream_error | 502 | The LLM API returned an error. | only when the upstream error was a 5xx |
ask_answer_incomplete | 502 | The model did not return a complete answer (ran out of output budget mid-answer, or omitted a required part of it). The credits are refunded, best-effort. | yes |
ask_question_refused | 422 | The model declined to answer. Deterministic for the same input — an identical retry fails identically. The credits are refunded, best-effort. | no |
retryable rather than the status code: a 502 ask_upstream_error
caused by an upstream 4xx will not succeed on retry. Retryable failures
(ask_busy, ask_timeout, and the upstream 429/502s) carry a
Retry-After response header in seconds — wait at least that long before
retrying, and prefer exponential backoff on repeats.
Partial-failure handling
/v1/ask is resilient to source failures. If 2 of 5 planner-selected
fields fail (timeout, source returned null, layer crashed), the
synthesizer still answers with the 3 that came back, the response
confidence drops one bucket (see the >30% rule above), and the answer
prose notes the gap (“…tree canopy was unavailable; assessment based on
LCMS class alone…”). Failed fields are not silently hidden: diff the
trace’s fields_requested against the top-level fields_used to see
exactly which planner-selected fields the answer could not use. For
field-level failure records (source, error, retryable), make the
same request via /v1/fetch, which returns a
structured partial_failures array.
Every /v1/ask response also carries a top-level data_gaps array: the
requested fields that resolved to no value, each with the fetch layer’s reason.
It is computed from the fetched result itself — not from the prose — so it is
authoritative missing-data you can read next to the answer without trusting the
wording or diffing fields_requested against fields_used:
"data_gaps": [
{
"field": "parcel_zoning",
"reason": "source returned null: no Regrid parcel at this point"
}
]
data_gaps is [] when every requested field returned a value, and it appears
on both the buffered response and the streaming final frame.