Skip to content

Generation & traces

Ask a question, get a verified, cited answer

POST /v1/generate plans evidence under a token budget, generates an answer from that plan and nothing else, verifies every claim in the draft against the planned spans, and persists the whole assembly as an immutable trace. This page is the wire contract for the endpoint, the SSE event stream, and the trace it leaves behind.

Normative specs

Evidence planning: memory-planner. Prompt assembly, streaming, and cost caps: generation-orchestration. Claim decomposition and scoring: claim-verification. Implementation: core-engine/src/api/v1.rs (post_generate, get_trace), core-engine/src/planner/, core-engine/src/llm/, core-engine/src/verify/.

POST /v1/generate

Auth: x-api-key.

GenerateRequest
{
  "question": "Which open risks have severity 5 or higher?",
  "query": {"find": "RISK", "where": {"status": {"$eq": "open"}}},
  "semanticModel": "text-embedding-3-small",
  "k": 16,
  "expandDepth": 1,
  "atValidTime": null,
  "atTxTime": null,
  "budgetTokens": null,
  "model": null,
  "maxTokens": null,
  "temperature": 0.2,
  "stream": false,
  "allowUngrounded": false
}
Field Type Default Notes
question string required The user's question. Also the text embedded for semantic recall, when semanticModel is set. Empty (after trim) is 400 PLAN_INVALID.
query query-DSL object none Structured recall leg: any valid query-DSL document.
semanticModel string none Semantic recall leg: a registered embedding model name. Requires a configured [embedding] endpoint; 501 UNIMPLEMENTED otherwise.
k integer 16 Semantic top-k.
expandDepth integer 1 Hop expansion (0-2) from recalled roots.
atValidTime RFC3339 string or µs integer now Valid-time coordinate for recall.
atTxTime RFC3339 string or µs integer now Transaction-time coordinate for recall.
budgetTokens integer planner default Token budget for evidence packing (planner default 6000, auto-scaled by model context; see Memory planner).
model string llm.model_default Generation model. Validated against the operator allowlist; an unlisted model is 400 MODEL_NOT_ALLOWED.
maxTokens integer llm.max_output_tokens Output-token ask, clamped to the operator ceiling.
temperature number 0.2 Must be in [0, 2], else 400 PLAN_INVALID.
stream boolean false Server-Sent Events instead of one JSON response.
allowUngrounded boolean false Proceed with an empty evidence plan instead of 422 when recall finds nothing.

At least one of query / semanticModel is typically supplied; either can be used alone or both together (their recalled evidence is unioned before packing).

Non-stream response (200)

200 OK (trimmed)
{
  "draft": "Two open risks have severity 5 or higher: ...",
  "model": "claude-sonnet-4-5",
  "traceId": "0197f2a1-...",
  "promptModel": "v1",
  "planModel": "v1",
  "planHash": "b3:7f2a...",
  "promptHash": "b3:9c11...",
  "citations": [
    {"marker": "S1", "sourceId": "...", "sourceVersionTx": ..., "start": 120, "end": 340}
  ],
  "plan": {
    "budget": 6000,
    "used": 812,
    "spans": 4,
    "stats": { "...": "..." }
  },
  "usage": {"inputTokens": 1204, "outputTokens": 96},
  "verification": {
    "status": "verified",
    "claims": [
      {
        "text": "Supplier delay has severity 7.",
        "draftSpan": [0, 31],
        "verdict": "supported",
        "score": 0.91,
        "bestSpan": {"sourceId": "...", "sourceVersionTx": ..., "start": 120, "end": 210},
        "candidatesConsidered": 3,
        "citedMarkers": ["S1"],
        "numericGated": false
      }
    ]
  }
}
Field Notes
draft The delivered answer text. If verify.policy = redact capped an unsupported claim, this is the redacted text; the trace keeps the original.
model The model actually used (request model, or llm.model_default).
traceId UUIDv7. Pass to GET /v1/trace/:id.
promptModel / planModel Versioned formula identifiers, currently both "v1". A future formula change bumps these, never silently.
planHash BLAKE3 of the evidence plan's canonical serialization. Identical inputs always produce an identical planHash, across runs and platforms.
promptHash Hash of the assembled, citation-marked prompt.
citations The [S{n}] markers the prompt offered the model, each bound to a plan span.
plan.budget / plan.used Token budget and tokens actually packed.
plan.spans Count of spans selected into the plan.
plan.stats Planner recall/packing statistics (candidates considered, per-source caps, stranded budget, etc).
usage Provider-billed token counts.
verification See Verification result shape below.

Verification result shape

verification (status: verified)
{
  "status": "verified",
  "claims": [
    {
      "text": "...",
      "draftSpan": [0, 31],
      "verdict": "supported",
      "score": 0.91,
      "bestSpan": {"sourceId": "...", "sourceVersionTx": 1751500000000000, "start": 120, "end": 210},
      "candidatesConsidered": 3,
      "citedMarkers": ["S1"],
      "numericGated": false
    }
  ]
}

Each claim carries a banded verdict: supported (score >= 0.75 by default), partial ([0.50, 0.75)), or unsupported (< 0.50). score is the geometric fusion of embedding similarity and IDF-weighted lexical coverage against the best-matching plan span (bestSpan); numericGated: true marks a claim whose asserted number, date, or percentage had no match in the span, capping its score deep into unsupported regardless of topical similarity. citedMarkers are the [S{n}] markers the draft cited for that claim, hints only, an incorrect citation does not rescue an unsupported claim.

When decomposition fails, or the claims and plan spans were embedded under inconsistent models, verification degrades instead of lying:

verification (status: unavailable)
{
  "status": "unavailable",
  "reason": "..."
}

The response is still delivered; it is simply never presented as verified when it wasn't.

Streaming (stream: true)

The response becomes text/event-stream. Event names, in order:

sequenceDiagram
    autonumber
    participant C as Client
    participant Core as telha-core

    C->>Core: POST /v1/generate (stream true)
    loop while tokens arrive
        Core-->>C: event chunk (data delta)
    end
    Note over Core: stream ends, verification runs
    Core-->>C: event verification (status, claims)
    Core-->>C: event done (traceId, usage, hashes, citations)
Event Data Notes
chunk {"delta": "..."} One text increment as the provider streams it.
verification Same shape as the non-stream verification field Necessarily trails the stream: verdicts can only be computed once the full draft exists. Clients see draft text live; the badge/caveat UI paints after.
done {"traceId": ..., "usage": {"inputTokens": ..., "outputTokens": ...}, "planHash": ..., "promptHash": ..., "citations": [...]} Terminal event on success.
error {"message": "..."} A mid-stream provider failure. The stream ends immediately after; nothing is ever re-spliced into a second attempt once the first chunk has been delivered.
curl -N -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 risks have severity 5 or higher?", "stream": true}'
const res = await fetch("http://127.0.0.1:7625/v1/generate", {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({ question: "...", stream: true }),
});
for await (const event of parseSSE(res.body!)) {
  if (event.event === "chunk") process.stdout.write(JSON.parse(event.data).delta);
  if (event.event === "done") console.log(JSON.parse(event.data).traceId);
}
import httpx

with httpx.stream(
    "POST", "http://127.0.0.1:7625/v1/generate",
    headers={"x-api-key": key},
    json={"question": "...", "stream": True},
) as resp:
    for line in resp.iter_lines():
        ...  # parse the SSE frame

Failure modes

Status Code Meaning
400 PLAN_INVALID Empty question, or temperature outside [0, 2].
400 MODEL_NOT_ALLOWED model is not in the operator's llm.models_allowed allowlist.
400 QUERY_INVALID The query leg failed query-DSL validation.
400 UNKNOWN_MODEL semanticModel names a model not registered with telha vector register-model.
400 VECTOR_INVALID The semantic recall leg was malformed.
401 UNAUTHORIZED Missing or invalid API key.
422 INSUFFICIENT_EVIDENCE Recall produced zero evidence spans. Pass "allowUngrounded": true to generate anyway (the model is instructed to flag everything).
429 TENANT_BUDGET_EXCEEDED The tenant's token cap (daily or monthly) is exhausted; the message carries the reset time. The cap is a pre-flight reservation: estimated usage is reserved before the provider call and reconciled after, so a tenant at the edge of its cap cannot fire one arbitrarily large request past it.
501 UNIMPLEMENTED Generation ([llm]) or the embedding endpoint is not configured on this server.
502 PROVIDER_ERROR / EMBEDDING_FAILED The LLM or embedding provider returned an error.
503 PROVIDER_UNAVAILABLE A transient provider failure; retries (up to 2, jittered) were already exhausted. Retries only ever happen before the first chunk is delivered, a mid-stream failure is never retried, since that would splice two different generations under one verification pass.

GET /v1/trace/:id

Every generation persists a GenerationTrace record. Auth: x-api-key; traces are tenant-scoped, a trace id from another tenant is 404 NOT_FOUND.

curl -sS http://127.0.0.1:7625/v1/trace/0197f2a1-... -H "x-api-key: $KEY"
200 OK
{
  "kind": "generation",
  "queryId": "0197f2a1-...",
  "request": {
    "question": "Which open risks have severity 5 or higher?",
    "model": "claude-sonnet-4-5",
    "semanticModel": "text-embedding-3-small",
    "k": 16,
    "expandDepth": 1,
    "budgetTokens": null,
    "allowUngrounded": false,
    "stream": false
  },
  "planHash": "b3:7f2a...",
  "promptHash": "b3:9c11...",
  "models": {
    "gen": "claude-sonnet-4-5",
    "decompose": "claude-sonnet-4-5",
    "embed": "text-embedding-3-small",
    "scoreModel": "v1",
    "planModel": "v1",
    "promptModel": "v1"
  },
  "plan": {
    "budget": 6000,
    "used": 812,
    "spans": [
      {
        "sourceId": "...", "sourceVersionTx": 1751500000000000,
        "start": 120, "end": 210,
        "kind": "paragraph", "class": "truth",
        "weight": 0.84, "tokens": 42
      }
    ],
    "stats": { "...": "..." }
  },
  "draft": "Two open risks have severity 5 or higher: ...",
  "claims": [
    {
      "text": "Supplier delay has severity 7.",
      "draftSpan": [0, 31],
      "verdict": "supported",
      "score": 0.91,
      "bestSpan": {"sourceId": "...", "sourceVersionTx": 1751500000000000, "start": 120, "end": 210},
      "candidatesConsidered": 3,
      "citedMarkers": ["S1"],
      "numericGated": false
    }
  ],
  "verification": "verified",
  "usageInputTokens": 1204,
  "usageOutputTokens": 96,
  "ts": 1751500002000000
}

The trace is the full request summary (no secrets, ever), the plan that was selected (every span, its structural kind, its truth/cache class, and its packing weight), the prompt hash, every decomposed claim with its verdict and best-matching span, and the model lineage that produced each step. Span coordinates in the trace resolve back to exact source bytes through the same canonical-slice API that ingestion writes against, so a claim's bestSpan is always one lookup away from the literal text that grounded it. spans marked class: "cache" are derived artifacts (never truth); verification never treats them as evidence for a claim.

verification on the trace is a short string: "verified" or "unavailable: <reason>". plan.stats and per-span weights are the answer to "why didn't it cite X", rejected-by-budget or capped-by-source-diversity spans show up there even when they never made the prompt.

404 NOT_FOUND means no such trace exists for this tenant (wrong id, or a trace belonging to a different tenant).