Skip to content

Telha Quickstart

Zero to a grounded, verified, generated answer in about 10 minutes. This page uses only curl and the telha binary. Everything here was verified against the code as built (core-engine PR-085 state).

Conventions:

  • Default REST address is 127.0.0.1:7625. Probes GET /healthz and GET /readyz are unauthenticated; everything under /v1 needs the x-api-key header.
  • Every error is the uniform envelope {"code": "...", "message": "...", "request_id": "..."}, and every response carries an x-request-id header for log correlation.
  • Timestamps are microseconds since epoch (or RFC3339 strings where a time coordinate is accepted).

1. Get a binary

Pick one (details in the deployment runbook):

  • Windows dev/eval zip (runbook §4): extract anywhere and run telha.exe. The zip bundles the MinGW DLLs; a bare telha.exe without them dies instantly (exit 127 / STATUS_DLL_NOT_FOUND) unless C:\msys64\mingw64\bin is on PATH.
  • Docker (runbook §3): docker build -f core-engine/Dockerfile -t telha-core . from the repo root, then docker run.
  • Build from source: cargo build --release in core-engine/ (on Windows this needs the GNU toolchain; see the runbook).

Check it:

telha version
# telha-core 0.1.0 (x86_64, rust ...)

2. Create an API key

Keys are scoped to a (tenant UUID, organization UUID) pair; you choose both. The plaintext is printed once; only a hash is stored in {data_dir}/api_keys.json. The server loads that file at startup, so mint the key before serve (or restart after).

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; shown once

api-key create opens the data dir directly, so run it while the server is stopped.

3. Serve

telha serve --data-dir ./data --rest-addr 127.0.0.1:7625

Or with a config file (--config or the TELHA_CONFIG env var). Any whitelisted config key can also come from TELHA_* env vars with __ as the nesting separator, e.g. TELHA_REST__ADDR=0.0.0.0:7625.

curl -fsS http://127.0.0.1:7625/healthz     # -> ok
export KEY=<plaintext from step 2>
curl -fsS http://127.0.0.1:7625/v1/whoami -H "x-api-key: $KEY"
# {"organization_id":"2222...","request_id":"...","tenant_id":"1111..."}

4. Ingest something

4a. Inline JSON (synchronous)

format=json is applied directly, no worker needed. Exactly one of content / contentBase64 is required.

curl -fsS -X POST http://127.0.0.1:7625/v1/ingest \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "format": "json",
    "name": "risks-2026-07.json",
    "content": "[{\"label\": \"RISK\", \"title\": \"Supplier delay\", \"severity\": 7, \"status\": \"open\"}]"
  }'

Response is 201 Created with the created graph nodes:

{"deduplicated": false, "sourceId": "...", "sourceVersionTx": ..., "nodeIds": ["..."], "edgeIds": []}

Re-POST the same bytes and you get 200 with {"deduplicated": true, "sourceId": "..."} - content-hash dedup.

4b. A file via contentBase64

Binary content rides base64. The same JSON path works for JSON bytes; document formats (pdf/docx/pptx via the docling worker, csv/xlsx via the tabular worker, plus web, email, and code) are parsed asynchronously by the Python format workers, so those requests return 202 Accepted with a job to poll:

B64=$(base64 -w0 report.pdf)
curl -fsS -X POST http://127.0.0.1:7625/v1/ingest \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d "{\"format\": \"pdf\", \"name\": \"report.pdf\", \"contentBase64\": \"$B64\"}"
# 202: {"deduplicated":false,"jobId":"...","sourceId":"...","statusUrl":"/v1/ingest/<jobId>"}

curl -fsS http://127.0.0.1:7625/v1/ingest/<jobId> -H "x-api-key: $KEY"
# {"jobId":"...","kind":"ingest:pdf","state":"pending|leased|completed|dead", "attempts":..., "events":[...]}

Note: the job stays pending until a docling worker is running against the server's gRPC listener (grpc.token_key set, worker started with python -m workers_common.run docling_worker). For the 10-minute path, format=json needs nothing extra.

5. Query it

POST /v1/query takes the JSON query DSL. Predicates are operator objects; bare scalars are rejected:

curl -fsS -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": "...", "labels": ["RISK"], "properties": {"severity": 7, "status": "open", ...},
               "validTime": {"start": ...}, "txTime": {"start": ...}, "tombstone": false}],
  "related": [], "nextCursor": null, "traceId": null,
  "stats": {"path": "...", "rowsScanned": ..., "rowsReturned": 1, "durationUs": ...}
}

Grammar in one breath: find (label, required), where with $eq/$ne/$gt/$gte/$lt/$lte/$in/$exists per property plus $and/$or blocks and dotted paths ("data.owner"), atValidTime/atTxTime (µs int or RFC3339), expand (up to 4 entries, depth 1..=3), vector ({text|near, model, k, minScore?}), limit (1..=1000), cursor, trace. A wrong query returns 400 QUERY_INVALID whose message carries a JSON pointer to the offending field:

curl -sS -X POST http://127.0.0.1:7625/v1/query \
  -H "x-api-key: $KEY" -d '{"find": "RISK", "where": {"severity": 5}}'
# {"code":"QUERY_INVALID","message":"... /where/severity ... operator object ...","request_id":"..."}

The full living grammar reference is the fixture corpus at core-engine/tests/fixtures/query_corpus.json (every valid and invalid shape, pinned by tests).

6. Time travel (30-second teaser)

Every record is bitemporal. Ask what was true at a moment (atValidTime), as known at a moment (atTxTime), or both:

curl -fsS -X POST http://127.0.0.1:7625/v1/snapshot \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"find": "RISK", "atValidTime": "2026-07-01T00:00:00Z"}'

And diff two moments with POST /v1/compare ({"baseline": {"validTime": ...}, "comparison": {"validTime": ...}, "find": ...}) to get added / removed / modified with per-property old and new values.

7. Generate a grounded answer

/v1/generate needs an LLM provider configured; semantic recall additionally needs an embedding endpoint and a registered embedding model. Config TOML (telha serve --config telha.toml):

[llm]
provider = "anthropic"                 # or "openai_compat"
endpoint = "https://api.anthropic.com" # https required for non-loopback hosts
model_default = "claude-sonnet-4-5"
api_key_env = "TELHA_LLM_API_KEY"      # env var NAME; the key never lives in config
# models_allowed = ["claude-haiku-4-5"]  # allowlist beyond model_default
# max_output_tokens = 4096
# [llm.tenant_caps] daily_tokens / monthly_tokens (0 = unlimited)

[embedding]
endpoint = "https://api.openai.com/v1" # OpenAI-compatible embeddings API
model = "text-embedding-3-small"       # default fan-out model; must be registered
api_key_env = "TELHA_EMBEDDING_API_KEY"

Register the embedding model once (server stopped; opens the data dir):

telha vector register-model --name text-embedding-3-small --dims 1536 \
  --normalize --provider-ref openai --data-dir ./data

Then set the key envs and restart serve. Generation fails fast at boot if llm.provider is set but the credential env is missing.

curl -fsS -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?",
    "query": {"find": "RISK", "where": {"status": {"$eq": "open"}}},
    "semanticModel": "text-embedding-3-small",
    "expandDepth": 1
  }'

Non-stream response (trimmed):

{
  "draft": "...",
  "model": "claude-sonnet-4-5",
  "traceId": "0197...",
  "planHash": "...", "promptHash": "...",
  "citations": [ ... ],
  "plan": {"budget": 6000, "used": ..., "spans": ..., "stats": { ... }},
  "usage": {"inputTokens": ..., "outputTokens": ...},
  "verification": {"status": "verified", "claims": [ ... ]}
}

Add "stream": true for Server-Sent Events. Event names, in order:

  • chunk - {"delta": "..."} text increments
  • verification - claim verdicts (necessarily trails the stream)
  • done - {"traceId": ..., "usage": {...}, "planHash": ..., "promptHash": ..., "citations": [...]}
  • error - {"message": ...} on mid-stream provider failure (never re-spliced)

Failure modes worth knowing: 422 INSUFFICIENT_EVIDENCE when recall found nothing (pass "allowUngrounded": true to proceed anyway), 400 MODEL_NOT_ALLOWED for a model outside the operator allowlist, 429 TENANT_BUDGET_EXCEEDED when the tenant token cap is exhausted, 501 UNIMPLEMENTED when [llm] is not configured.

8. Inspect the trace

Every generation persists a full assembly record: the request summary, evidence plan, prompt hash, per-claim verdicts, and span links back to source bytes.

curl -fsS http://127.0.0.1:7625/v1/trace/<traceId> -H "x-api-key: $KEY"

404 NOT_FOUND means no such trace for this tenant.

Where next