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>",
"max_cost_usd": "<string>",
"include_trace": false
}
'import requests
url = "https://api.example.com/v1/ask"
payload = {
"question": "<string>",
"lat": 123,
"lng": 123,
"address": "<string>",
"max_cost_usd": "<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>',
max_cost_usd: '<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>',
'max_cost_usd' => '<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 \"max_cost_usd\": \"<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 \"max_cost_usd\": \"<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 \"max_cost_usd\": \"<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>",
"max_cost_usd": "<string>",
"include_trace": false
}
'import requests
url = "https://api.example.com/v1/ask"
payload = {
"question": "<string>",
"lat": 123,
"lng": 123,
"address": "<string>",
"max_cost_usd": "<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>',
max_cost_usd: '<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>',
'max_cost_usd' => '<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 \"max_cost_usd\": \"<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 \"max_cost_usd\": \"<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 \"max_cost_usd\": \"<string>\",\n \"include_trace\": false\n}"
response = http.request(request)
puts response.read_body{
"detail": [
{
"loc": [
"<string>"
],
"msg": "<string>",
"type": "<string>",
"input": "<unknown>",
"ctx": {}
}
]
}The price is $0.10 per completed answer on every plan. Send a stable
The response gains a
Every response carries
The null downgrade is automatic: if more than 30% of the
planner-selected fields came back null, the response
Failures before the first byte (planner error, connection error, deadline,
or an at-capacity
The
An error with
Idempotency-Key and an optional decimal-string max_cost_usd (for example
"0.10"). Incomplete answers cost zero. The final response carries a dollar
billing receipt; streaming only bills a completed final answer. A disconnected
client can recover its operation with the same key. Current terms and licensed
credential permissions are required. See Pricing.
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
Planner traces are restricted to Mireye’s internal plan. Customer accounts receive the normal answer, citations,fields_used, and data_gaps without a trace, even
if they send "include_trace": true. The flag remains accepted for compatibility;
it does not grant access to internal diagnostics. This applies to both buffered
responses and streaming final frames.
Authorized internal callers can pass "include_trace": true to include 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 | Recovery after confirmed failure |
|---|---|---|---|
ask_busy | 429 | The fleet-wide /v1/ask concurrency cap is saturated; no model work started. | Retry the same key. |
ask_timeout | 504 | The request exceeded the 110 s end-to-end deadline. | Submit a new request with a new key. |
ask_upstream_rate_limited | 429 | The LLM API rate-limited the call. | Submit a new request with a new key after the upstream recovers. |
ask_upstream_unreachable | 502 | Could not reach the LLM API (connection error). | Submit a new request with a new key. |
ask_upstream_error | 502 | The LLM API returned an error. | An upstream 5xx may succeed as a new request; an upstream 4xx needs correction. |
ask_answer_incomplete | 502 | The model did not return a complete answer. The answer is not charged. | Submit a new request with a new key. |
ask_question_refused | 422 | The model declined to answer. The answer is not charged. | Rephrase the question; repeating it unchanged will fail again. |
operation_status: "failed" and retryable: false confirms a
terminal operation, even when a new attempt could succeed. Its key cannot restart
work. Explicitly submit a new request with a new idempotency key to try again;
a successful new request is billed at the normal price. Never automatically
rotate keys after an interruption or uncertain outcome: recover the original
operation first. Before work starts, retryable capacity and temporary admission
failures keep the same key and carry Retry-After in seconds. Follow that
hint and use backoff.
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…”). Read the public data_gaps array below to identify
requested fields with no value and their reasons; no internal trace is needed.
For fields supported by public /v1/fetch, request those
fields there to inspect its structured partial_failures records
(source, error, retryable).
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 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.
Licensed parcel evidence is included in the flat answer price. There is no
additional parcel charge or credit withholding tier. Current terms and licensed
grants are checked before source work and saved-answer delivery. The dollar
receipt reflects the entire completed answer, including its selected evidence.Headers
Body
application/json
Response
Successful Response