Skip to content

HTTP API

The HTTP API is versioned under /v1, and v1 is frozen: changes are additive only (new optional fields), and no field is ever removed or renamed. Breaking changes would go to /v2.

All bodies are JSON. When the server has API keys, send Authorization: Bearer curva_… on every route except /health (see Auth).

Every response carries an x-request-id header. Send your own x-request-id (1–64 visible ASCII characters) to follow a request across services; otherwise the server generates one. The same id appears in the server’s log line for the request.

Answers typed questions about a state.

Request

{
"model": "inclusionai/ling-3.0-flash-fin:free",
"mode": "auto",
"state": { "ticket": "I was charged twice for A-104, refund please" },
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this",
"options": { "billing": "Payments, refunds", "technical": "Bugs", "sales": "Pricing" }
},
"frustration": {
"type": "score",
"instructions": "How frustrated the customer is",
"levels": ["Calm", "Frustrated but civil", "Very angry"]
},
"refund_requested": { "type": "noul", "instructions": "The customer explicitly asks for a refund" }
}
}

Response (real output)

{
"id": "dec_19294a3c1f2000000",
"model": "inclusionai/ling-3.0-flash-fin:free",
"mode": "logprobs",
"latency_ms": 1144,
"cost_usd": 0.0,
"cached": false,
"answers": {
"department": { "choice": "billing", "probabilities": { "billing": 0.9999, "technical": 0.0, "sales": 0.0001 }, "confidence": 0.9999 },
"frustration": { "score": 0.65, "probabilities": [0.36, 0.62, 0.02], "confidence": 0.62 },
"refund_requested": { "noul": 0.999 }
}
}
Field Required Meaning
state yes Any JSON (string, object or array). At most 150,000 characters (about 32k tokens)
questions yes 1–64 questions, key → question. Order is kept in the response
model no One of:
• a model id
• a list tried in order until one answers (fallback chain)
• {"council": [...]}: 2–5 models answer concurrently and are blended; answers get agreement
• {"cascade": [...], "escalate_below": 0.8}: cheapest first, and only unsure questions go to the next model; answers get answered_by
• {"race": [...]}: all asked at once; the first valid answer wins
Defaults to the server’s --model
mode no auto (default: logprobs if the model returns them, else verbal), logprobs or verbal
debias no Default true: ask with the options in original and reversed order, concurrently, and average. false halves the calls. "auto": debias until a (model, question) has shown no position bias (at least 20 paired answers, 95% of them agreeing: same top label, top probability within 0.1), then ask only the original order, with the full pair on every 10th request to keep checking; one disagreement starts it over. What it learned is kept in memory per server process (at most 10,000 model-question pairs)
think no Default false. true lets the model reason briefly before answering, for hard questions: always verbal mode (mode: logprobs gets 422), 1,024 more output tokens for the reasoning, and on OpenRouter reasoning: {"effort": "low"}. Slower and costlier; part of the cache key
project no Calibration namespace (default "default"). Feedback and calibrators are kept per project
config no A pinned config: curva-1.0.0, curva-1.1.0 or curva-1.2.0 (questions before the state, so providers and local servers can cache the repeating part of the prompt). A pin freezes the prompt template and the default mode, debias and model. curva-latest (the default) is whichever pin the operator chose with --latest-config. Fields set on the request still win. Unknown names get 422
privacy no standard (default) or strict: only providers that neither store nor train on prompts are used, and extracted values are returned but never stored (the audit log shows them as redacted)
explain no true adds explain: {field: drop in probability} to each answer. Leave-one-out over at most 12 top-level fields of an object state, with one extra call per field. Not with depends_on (422)
images no Up to 8 images judged with the state, for vision models: https:// URLs (at most 2,048 characters, fetched by the model provider) or data:image/<png|jpeg|webp|gif>;base64,... URIs (at most 5 MB decoded each). Part of the cache key; the audit log keeps only a salted SHA-256 of each. See Images
Field Types Meaning
type all choice, score, noul or multi, or the extraction types text, number and integer (answered in verbal mode only; mode: logprobs gets 422)
instructions all The question in plain words
options choice Key → description (may be ""), 2–255 options, plus the escape option. Over 20 options a question is answered in verbal mode, since logprobs only return the top 20; mode: logprobs then gets 422
escape choice Default true: adds a none_of_these option so the model is never forced into a wrong pick (the key is reserved)
levels score 2–20 descriptions, lowest first
min_confidence choice, score, text, number, integer 0–1. The answer gets abstain: true when its confidence is below this
coverage choice, multi, noul Strictly between 0 and 1; 422 otherwise and on other types. Once the question has 30 feedback labels, the answer gains a set that contains the true answer with probability ≥ coverage. It is not part of the cache key (guide)
options, threshold multi 1–20 options; each is scored as its own yes/no (in the same call). Options with P ≥ threshold (default 0.5) are selected
max_length text Most characters in the value: 1–2000, default 200
min, max number, integer Inclusive bounds on the value (whole numbers for an integer)
nullable text, number, integer true: the answer may be null when the state has no such value (default false)
examples choice, score, noul, text, number, integer Up to 10 {"state": ..., "label": ...} few-shot examples (label as in feedback)
when all {"<state field>": value or [values]}: ask only when every named top-level field of an object state matches; otherwise the answer is {"skipped": true} and no model is called for it. Needs an object state (else 422) unless every field is an @key: "@<key>" reads question <key>’s answer (option key, true/false by P(yes) ≥ 0.5, level index, selected keys, or the value) and makes it a dependency (guide)
depends_on all Keys of questions to answer first, in an earlier stage of the same request; this question sees their answers. Unknown keys, self-dependencies, cycles and more than 8 stages get 422 (guide)
think all true: this question reasons in a verbal call of its own, concurrently with its stage’s normal call (default false)
rules all Up to 32 {"if": {"<state field>": condition, ...}, "answer": label}. A condition is a value (equals), a list (any of) or an operator object: contains (case-insensitive), starts_with, gt, gte, lt, lte, exists. The first rule whose conditions all hold answers the question with no model call; answer is a label as in feedback (a list of option keys for a Multi; the value for text, number and integer). Checked after when; conditions may read @key answers too. Needs an object state; unknown operators and invalid answers get 422 (guide)
Field Meaning
id dec_…: unique and sortable by time
mode The mode that actually answered (auto resolves to logprobs or verbal)
latency_ms, cost_usd Time and model cost for this decision (both 0 when cached), over every model call; calls in one stage run concurrently
cached true = served from the decision cache: no model call (with stages: every call)
debiased true = asked in both option orders and averaged. false with debias: false, when debias: "auto" asked only the original order, or when one of the two orders failed
project The calibration namespace used
config The pinned config the decision ran under (e.g. curva-1.1.0)
answers.<key> See below

Every answer has calibrated (true once the project’s feedback has fitted a calibrator for this exact question), plus:

Type Fields
Choice choice (may be none_of_these), probabilities (per option), confidence, abstain*
Score score (expected level, 0 = lowest), probabilities (per level), confidence, abstain*
Noul noul = P(yes)
Multi selected, probabilities (independent, per option)
Text, Number, Integer value (null when the state has none, or the reply had no valid value), confidence = P(value is correct), abstain*

* only when min_confidence was set. Multi-model plans and explain may add agreement, answered_by and explain. With depends_on, every answer has stage (0-based). A question skipped by its when, or whose dependency was skipped, is {"skipped": true}. A question answered by one of its rules has all probability on that answer (confidence: 1.0), calibrated: false and rule: the index of the rule that matched.

A question that set coverage echoes it and adds guaranteed. With 30 or more labels for this exact question, guaranteed is true and set lists the option keys (choice, multi) or the "true"/"false" values (noul) that contain the true answer with probability ≥ coverage. With fewer labels, guaranteed is false and set is absent. A Multi reports guaranteed: false for now, because feedback labels a Multi as a whole and not option by option.

Text, number and integer questions go into the same verbal call as the label questions, each with its own reply schema {"value": <typed>, "confidence": number}. A value of the wrong type, out of range, too long, or null when not nullable counts as no answer (value: null, confidence: 0). With debias, they are not reordered; if the two calls disagree, the original-order value is kept at the lower confidence. A council takes the majority value (agreement = its share; confidence = the agreeing members’ mean × agreement; a tie goes to the more confident side); a cascade escalates when confidence < escalate_below; explain skips them. See Extraction.

Every error has the shape {"error": {"type": "...", "message": "..."}}.

Status type When
400 invalid_json The body is not JSON, or has an unknown mode or question type
401 unauthorized The server has API keys and none (or an unknown or revoked one) was sent
404 not_found Unknown decision, question or route
413 too_large The request body is over the server’s limit (curva serve --max-body-mb, default 16 MB)
422 invalid_request Too few or too many options, bad max_length/min/max, mode: logprobs with a text, number or integer question, more than 64 questions, state too large, more than 8 images or an invalid one; the message names the question or image
429 rate_limited The API key is over its requests per minute, the model provider is rate-limiting, or the server’s daily budget is spent. Always carries Retry-After (seconds)
502 model_error Every model in the chain failed after retries (details stay in the server log)
502 model_unavailable The model doesn’t exist, was retired or isn’t open to the server’s provider account: check the model id or your access (details stay in the server log)
504 timeout The request took longer than the 120 s deadline

Records the true answer for one question of a past decision. From 30 labels for the same exact question in a project, a calibrator is fitted, and future answers come back with calibrated: true whenever it makes them more accurate. It is refitted as labels arrive. Sending feedback again for the same decision and question replaces the earlier label.

{ "decision_id": "dec_…", "question": "department", "label": "billing" }
{ "decision_id": "dec_…", "question": "department", "labels": 31, "min_labels": 30, "calibrated": true }
  • label: option key (Choice), level index (Score), true/false (Noul), or the true value (Text, Number, Integer; null only when nullable). It counts as correct when it matches the answer’s value: text ignoring case and extra whitespace, numbers within a relative 1e-6.
  • Errors: 404 for an unknown decision or question, 422 for a label that isn’t a valid answer.

GET /v1/calibration?question=<key>&project=<name>

For the most recent wording of the question, returns:

  • labels, min_labels
  • calibrator: {"kind": "temperature", "t": 1.8} for Choice and Score, or {"kind": "platt", "a": …, "b": …} for Noul, Text, Number and Integer
  • before: raw probabilities
  • after: held out. Each half of the labels is calibrated by a fit on the other half.

before and after each contain accuracy, ece, brier, automated and accuracy_when_automated (at 0.9), plus reliability bins.

GET /v1/audit?project=<name>&limit=100&before=<decision id>

Every decision, newest first: {"project", "entries": [...], "next_before"}. Pass next_before as before for the next page (at most 1,000 per page). Each entry has id, project, api_key (the key id), state_hash (a salted SHA-256 of the state; the state itself is never stored), model, mode, config, privacy, answers (as returned), latency_ms, cost_usd, cached, created_at (unix ms) and, for decisions with images, images (a salted SHA-256 of each image as sent; the images themselves are never stored).

GET /v1/drift?question=<key>&project=<name>&weeks=8

One question’s answers per ISO week, oldest first: {"project", "question", "weeks": [{"week", "decisions", "mix", "confidence"}, ...]}. mix is the share of decisions per top answer and confidence the average probability of the top answer (raw). The latest week also has mix_change (total-variation distance from the earlier weeks’ average mix), confidence_change, and drift: true when the mix moved by more than 0.2 or confidence by more than 0.1. For text, number and integer questions mix is null, there is no mix_change, confidence is the average P(value is correct), and only confidence can flag drift. weeks counts the current week (default 8, at most 53). See Drift.

{"models": [{"name", "description", "release_date"}]}: curva-latest and every pinned config.

A compatible alias of /v1/decide that accepts the criteria request shape used by many typed-decision clients, so they work by changing only their base URL and API key.

  • Request: {"state": ..., "model": "...", "questions": {...}}, plus any /v1/decide field (project, debias, privacy, …).
    • Choice: criteria = key → description (text, null or JSON). No escape option unless the question sets "escape": true.
    • Score: criteria = level descriptions, lowest first.
    • Noul: optional criteria: {"true": ..., "false": ...}.
    • {"type": "string"} without enum is a text question (its answers come back with type: "string"); number and integer work as is.
    • instructions may be omitted, or be JSON (sent to the model as text).
    • model: curva-x.y.z or curva-latest selects that config; a name without a provider prefix means the server default; anything else is a model id, list or plan as in /v1/decide.
  • Response: the /v1/decide response plus usage: {"input_tokens", "output_tokens"} (output is not tracked and is always 0). Every answer has type; a Score’s probabilities and legend (the level descriptions) are keyed by level: {"0": ..., "1": ...}.

{"status": "ok", "version": "0.1.0"}. Never needs an API key.

Prometheus text format: curva_decisions_total, curva_cache_hits_total, curva_answers_total, curva_abstains_total, the curva_decide_duration_seconds histogram, curva_http_responses_total{status} (502 = provider error) and, with CURVA_DAILY_LIMIT, curva_daily_quota_remaining.

Once any API key exists (curva keys create --name <who> [--rpm 600] [--project <p>]), every route except /health, the dashboard page (GET /dashboard) and GET /openapi.json (neither holds any data) needs Authorization: Bearer curva_…. Keys are stored as SHA-256 hashes and rate-limited per key (bursts up to 10 seconds’ worth). A key made with --project works only for that project: other projects get 403, and another project’s decisions look like they don’t exist (404). Without keys, the server only listens on localhost unless started with --no-auth. See Self-hosting.

© 2026 Tarkova Private Limited.