Ingest and ask a verified question¶
Tutorial · 1 of 5
In this tutorial you ingest a handful of supplier risk records, ask a plain-English question about them, and read back an answer that is grounded in those exact records, cited by source span, and verified claim by claim. This is the flagship Telha loop: ingest, query, generate, verify.
You will need
- A
telhabinary on your PATH (installation) - A running server and an API key (quickstart, steps 2-3)
- An
[llm]provider configured for/v1/generate(quickstart step 7) -
curl
Scenario¶
You are tracking supplier risk for a procurement team. You have a short list of open risk items and you want to ask, in plain language, which ones matter most right now, and get an answer you can actually trust.
1. Get a key and start the server¶
If you already have a key and a running server from the quickstart, skip to step 2.
export TENANT=11111111-1111-4111-8111-111111111111
export ORG=22222222-2222-4222-8222-222222222222
telha api-key create --tenant $TENANT --org $ORG --data-dir ./data
# tk_... <- save this, it is shown once
telha serve --data-dir ./data --rest-addr 127.0.0.1:7625
In a second terminal:
export KEY=<the tk_... value from above>
curl -fsS http://127.0.0.1:7625/v1/whoami -H "x-api-key: $KEY"
Checkpoint
whoami returns {"organization_id":"...","request_id":"...","tenant_id":"..."}. If you get 401 UNAUTHORIZED, the key was not loaded, restart serve after api-key create.
2. Ingest a small set of supplier risks¶
format=json applies synchronously, no worker needed. Ingest four RISK records in one call:
curl -sS -X POST http://127.0.0.1:7625/v1/ingest \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{
"format": "json",
"name": "supplier-risks-2026-07.json",
"content": "[{\"label\": \"RISK\", \"title\": \"Supplier delay: Acme Fasteners\", \"severity\": 8, \"status\": \"open\", \"supplier\": \"Acme Fasteners\"}, {\"label\": \"RISK\", \"title\": \"Single-source dependency: Globex Resin\", \"severity\": 7, \"status\": \"open\", \"supplier\": \"Globex Resin\"}, {\"label\": \"RISK\", \"title\": \"Late customs clearance: Northwind Freight\", \"severity\": 4, \"status\": \"open\", \"supplier\": \"Northwind Freight\"}, {\"label\": \"RISK\", \"title\": \"Quality escape resolved: Initech Molding\", \"severity\": 6, \"status\": \"closed\", \"supplier\": \"Initech Molding\"}]"
}'
Each object became a RISK node. format=json with no options.label defaults the label to the UPPER_SNAKE form of the request name with its extension stripped, we passed "label" inline per record instead by using the array-of-objects shape, so every record already carries its own label.
Dedup is automatic
Re-POST the exact same bytes and you get 200 {"deduplicated": true, "sourceId": "b6b2..."} with no new rows. Corrections and additions always go through a fresh content, see Ingestion.
3. Query the open, high-severity risks¶
Predicates are operator objects, never bare scalars. Find open risks with severity 5 or higher:
curl -sS -X POST http://127.0.0.1:7625/v1/query \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"find": "RISK", "where": {"severity": {"$gte": 5}, "status": {"$eq": "open"}}, "limit": 10}'
{
"records": [
{ "id": "9c11...", "labels": ["RISK"],
"properties": { "title": "Supplier delay: Acme Fasteners", "severity": 8, "status": "open", "supplier": "Acme Fasteners" },
"validTime": {"start": 1751500000000000}, "txTime": {"start": 1751500000000000}, "tombstone": false },
{ "id": "a2f0...", "labels": ["RISK"],
"properties": { "title": "Single-source dependency: Globex Resin", "severity": 7, "status": "open", "supplier": "Globex Resin" },
"validTime": {}, "txTime": {}, "tombstone": false }
],
"related": [], "nextCursor": null, "traceId": null,
"stats": { "path": "...", "rowsScanned": 4, "rowsReturned": 2, "durationUs": 210 }
}
Checkpoint
Two records come back: Acme Fasteners (severity 8) and Globex Resin (severity 7). The Northwind item is filtered out by severity, Initech by status. This is the same query-DSL document that /v1/generate will use as its structured recall leg. See the query language reference for the full grammar.
4. Ask a verified question¶
POST /v1/generate plans evidence from a query (and, optionally, semantic recall), drafts an answer from that plan only, and verifies each claim against the planned source spans before handing it back:
curl -sS -X POST http://127.0.0.1:7625/v1/generate \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{
"question": "Which open supplier risks have severity 5 or higher, and how severe is each?",
"query": {"find": "RISK", "where": {"severity": {"$gte": 5}, "status": {"$eq": "open"}}},
"expandDepth": 1
}'
{
"draft": "Two open supplier risks have severity 5 or higher: the Acme Fasteners supplier delay (severity 8) [S1] and the Globex Resin single-source dependency (severity 7) [S2].",
"model": "claude-sonnet-4-5",
"traceId": "0197f2a1-...",
"planHash": "b3:7f2a...",
"promptHash": "b3:9c11...",
"citations": [
{"marker": "S1", "sourceId": "b6b2...", "sourceVersionTx": 1751500000000000, "start": 0, "end": 118},
{"marker": "S2", "sourceId": "b6b2...", "sourceVersionTx": 1751500000000000, "start": 119, "end": 235}
],
"plan": {"budget": 6000, "used": 312, "spans": 2, "stats": {}},
"usage": {"inputTokens": 540, "outputTokens": 62},
"verification": {
"status": "verified",
"claims": [
{
"text": "The Acme Fasteners supplier delay has severity 8.",
"draftSpan": [23, 78], "verdict": "supported", "score": 0.93,
"bestSpan": {"sourceId": "b6b2...", "sourceVersionTx": 1751500000000000, "start": 0, "end": 118},
"candidatesConsidered": 2, "citedMarkers": ["S1"], "numericGated": false
},
{
"text": "The Globex Resin single-source dependency has severity 7.",
"draftSpan": [83, 143], "verdict": "supported", "score": 0.90,
"bestSpan": {"sourceId": "b6b2...", "sourceVersionTx": 1751500000000000, "start": 119, "end": 235},
"candidatesConsidered": 2, "citedMarkers": ["S2"], "numericGated": false
}
]
}
}
Notice what came back beyond the draft text:
| Field | What it tells you |
|---|---|
citations | The [S1], [S2] markers the model was offered, each bound to an exact byte span in the ingested source |
plan.spans | How many evidence spans were actually packed into the prompt |
verification.claims[].verdict | supported (score ≥ 0.75 by default), partial ([0.50, 0.75)), or unsupported (< 0.50), per claim |
verification.claims[].bestSpan | The exact span each claim was checked against |
traceId | Pass this to GET /v1/trace/:id for the full assembly record |
Checkpoint
Both claims verify as supported. If your draft phrases things slightly differently, that is expected, the LLM composes the sentence; verification confirms the numbers and facts match the source, not the exact wording.
5. Inspect the trace¶
Every generation persists a full assembly record: the request, the evidence plan (every span, its structural kind, its packing weight), the prompt hash, and every decomposed claim with its verdict.
{
"kind": "generation",
"queryId": "0197f2a1-...",
"request": { "question": "Which open supplier risks have severity 5 or higher...", "model": "claude-sonnet-4-5", "expandDepth": 1 },
"planHash": "b3:7f2a...",
"promptHash": "b3:9c11...",
"models": { "gen": "claude-sonnet-4-5", "decompose": "claude-sonnet-4-5", "scoreModel": "v1", "planModel": "v1", "promptModel": "v1" },
"plan": { "budget": 6000, "used": 312, "spans": [ {"sourceId": "b6b2...", "start": 0, "end": 118, "kind": "body", "class": "truth", "weight": 0.84, "tokens": 28} ], "stats": {} },
"draft": "Two open supplier risks have severity 5 or higher: ...",
"claims": [ {"text": "The Acme Fasteners supplier delay has severity 8.", "verdict": "supported", "score": 0.93, "citedMarkers": ["S1"]} ],
"verification": "verified",
"usageInputTokens": 540, "usageOutputTokens": 62,
"ts": 1751500002000000
}
plan.stats and per-span weights are the answer to "why didn't it cite X", spans rejected by the token budget or capped by source diversity show up there even when they never reached the prompt. A trace id from another tenant, or a typo'd id, is 404 NOT_FOUND.
When there is no evidence¶
Ask a question with a query that matches nothing, and generation refuses rather than making something up:
curl -sS -X POST http://127.0.0.1:7625/v1/generate \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"question": "What is our exposure to a supplier named Wayne Enterprises?", "query": {"find": "RISK", "where": {"supplier": {"$eq": "Wayne Enterprises"}}}}'
{
"code": "INSUFFICIENT_EVIDENCE",
"message": "recall produced zero evidence spans",
"request_id": "..."
}
Do not silently swallow this
422 INSUFFICIENT_EVIDENCE means recall genuinely found nothing to ground an answer in, this is Telha refusing to guess. If you deliberately want a best-effort answer anyway (for exploratory use, never for anything presented as fact), pass "allowUngrounded": true:
curl -sS -X POST http://127.0.0.1:7625/v1/generate \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"question": "What is our exposure to Wayne Enterprises?", "query": {"find": "RISK", "where": {"supplier": {"$eq": "Wayne Enterprises"}}}, "allowUngrounded": true}'
The model is instructed to flag everything it says as unconfirmed; every claim in the resulting verification.claims should come back unsupported, since there was no evidence to check against.
What you learned¶
- How to ingest a small batch of records synchronously with
format=json - How to filter with the query DSL's operator-object
whereclause - How
/v1/generateplans evidence, drafts an answer, and verifies each claim against source spans - How to read
citations,verification.claims[].verdict, andplan.spans - How to pull the full assembly record back with
GET /v1/trace/:id - Why
422 INSUFFICIENT_EVIDENCEhappens and whenallowUngroundedis (and is not) appropriate
Related¶
- Query language - the full
where/expand/vectorgrammar - Generation & traces - the complete
/v1/generatecontract and verification model - Ingestion - formats, dedup, and provenance spans in depth
- Time-travel with snapshots and compare - the next tutorial
- Concepts › Verified answers - the plan/generate/verify loop, explained