Skip to content

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

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\"}]"
  }'
{
  "deduplicated": false,
  "sourceId": "b6b2...",
  "sourceVersionTx": 1751500000000000,
  "nodeIds": ["9c11...", "a2f0...", "7de4...", "51bb..."],
  "edgeIds": []
}

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}'
Response (trimmed)
{
  "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.

curl -fsS http://127.0.0.1:7625/v1/trace/0197f2a1-... -H "x-api-key: $KEY"
Response (trimmed)
{
  "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"}}}}'
422 INSUFFICIENT_EVIDENCE
{
  "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 where clause
  • How /v1/generate plans evidence, drafts an answer, and verifies each claim against source spans
  • How to read citations, verification.claims[].verdict, and plan.spans
  • How to pull the full assembly record back with GET /v1/trace/:id
  • Why 422 INSUFFICIENT_EVIDENCE happens and when allowUngrounded is (and is not) appropriate