# Answer questions in plain words

Source: https://docs.littlebigbrain.com/guides/answer-questions/

Your users ask questions in plain words: "How many services does each team
run?" A SPARQL query answers it. To write that query, a model must know your
classes and properties. It must also know the rules of SPARQL on little
big brain. It should look at the rows before it answers.

`POST /v1/query/ask` does these steps on the server. A model runs queries in
a short loop, reads their rows, and answers in plain words. The response
holds the answer, the records it cites, and the query whose rows hold the
answer, with its rows. Your app shows the answer beside the rows, and can
draw a chart of them: the answer says which kind of chart fits.

LBB runs the models with its own keys and pays for the calls. You choose no
model and bring no key.

## How it works

1. **The server describes the graph.** It reads what it keeps about your
   graph: the classes and their counts, and the properties of the largest
   classes. Each property gets a short example value. The description also
   holds the OWL and RDFS statements and the classes you can search by
   meaning. The server keeps it for 10 minutes, so the next questions reuse
   it. A graph with many classes gets a short extra part for each question:
   the classes and properties whose names match the question's words, with
   all their properties. Your graph's [profile](#notes-for-every-question)
   comes with it.
2. **A decision model picks the route.** TypeSafe's Jev picks the kind of
   question. It gives a probability for each kind. At the same time, the
   server looks up the names in the question ("TU Dresden", "Quelmann") and
   finds the records they name. See
   [Names in the question](#names-in-the-question).
3. **A reasoning model looks at the data.** Claude Sonnet 5.5 works in steps,
   as a person at a SPARQL console does. It runs a query and reads the row
   count and the first 30 rows. It looks up a name, reads the description of
   some classes, finds the commit of a date, or compares two points in time.
   It corrects a query that failed or returned too few rows. The server
   checks each query: only `SELECT` and `ASK` run, and a known prefix that
   the query uses without a `PREFIX` line gets one.
4. **The model answers.** It answers in short plain words, names the records
   it read, and points to the query whose rows hold the answer. It takes at
   most 4 rounds of steps; then it must answer.

A route is one kind of question:

| Route | The question needs |
| --- | --- |
| `lookup` | Records or facts that match conditions: values, links, paths. |
| `aggregate` | Numbers: counts, sums, averages, rankings, groups. |
| `search` | Records by meaning. The queries use `search:similarTo` on a class you can [search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). |
| `history` | The graph at an earlier commit or date, or what changed. |
| `schema` | The graph's own model: classes, properties and how they connect. |
| `unanswerable` | Data that the graph does not hold. The answer says what is missing. |

Until 2026-10-05 the route was `POST /v1/query/rewrite`. Its default mode
wrote one query without a look at your data, and `run: true` ran it. That
mode is gone. The old path answers 404 and names the new one. To write your
own queries in your own loop, use [the tools](#use-the-tools-in-your-own-agent).

## The example graph

The examples use a small service catalog. It has three services, the team
that owns each, and two incidents. Load it as Turtle into a graph named
`services`, as [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/) shows:

```turtle
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix c: <https://x.test/class/> .
@prefix e: <https://x.test/e/> .
@prefix p: <https://x.test/p/> .
c:service a rdfs:Class ; rdfs:comment "A running program that a team owns." .
c:authenticationincident rdfs:subClassOf c:incident .
e:risk a c:service ; rdfs:label "risk-service" ; p:description "scores card payments for fraud" ; p:team e:payments ; p:tier 1 .
e:ledger a c:service ; rdfs:label "ledger-service" ; p:description "books card payments into the ledger" ; p:team e:payments ; p:tier 2 .
e:auth a c:service ; rdfs:label "auth-service" ; p:description "signs users in with passwords" ; p:team e:identity ; p:tier 1 .
e:payments a c:team ; rdfs:label "payments team" .
e:identity a c:team ; rdfs:label "identity team" .
e:outage a c:incident ; rdfs:label "checkout outage" ; p:description "card payments failed at checkout" .
e:login a c:authenticationincident ; rdfs:label "login failures" ; p:description "users could not sign in" .
```

The `search` route needs an embedding. Declare one on the services, from
their label and description:

```sh
curl -X PUT "$LBB_URL/v1/embeddings?graph=services" \
  -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \
  --data '{"class": "https://x.test/class/service", "from": ["label", "description"]}'
```

## Ask a question

### cURL

```sh
curl -X POST "$LBB_URL/v1/query/ask?graph=services" \
  -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \
  --data '{"question": "How many services does each team run?",
           "context": "Teams own services."}'
```

### TypeScript

```ts
import { LbbClient } from "@littlebigbrain/client";

const lbb = new LbbClient({
  baseUrl: process.env.LBB_URL!,
  apiKey: process.env.LBB_API_KEY!,
  graph: "services",
});

const { answer, citations, rows } = await lbb.query.ask(
  "How many services does each team run?",
  { context: "Teams own services." },
);
console.log(answer, citations);
console.log(rows);
```

`query.ask(question, opts)` returns `answer`, `citations`, `chart`, `steps`,
`route`, `query`, `rationale`, the parsed `rows` and `vars`, `history`,
`error` and `traceId`. `response` holds the whole response.

### Python

```python
import os

from lbb import LbbClient

client = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="services")

result = client.query.ask(
    "How many services does each team run?",
    context="Teams own services.",
)
print(result.answer, result.citations)
for row in result.rows:
    print(row)
```

`client.query.ask(...)` returns `answer`, `citations`, `chart`, `steps`,
`rows`, `vars`, `route`, `query`, `rationale`, `history`, `error` and
`trace_id`.
`response` holds the whole response. The async client has the same method.

### MCP

```text
lbb_query mode=question question="How many services does each team run?"
```

`mode: "question"` asks `POST /v1/query/ask` and returns the answer. It also
takes `context`, `route`, `limit`, `anchor` and `timeline`.

## What the response holds

This excerpt shows the answer, the route, the step and the model calls of
the question above:

```json
{
  "answer": {
    "text": "The payments team runs 2 services. The identity team runs 1 service.",
    "citations": ["https://x.test/e/payments", "https://x.test/e/identity"],
    "chart": { "kind": "bar", "x": "l", "y": "n" }
  },
  "route": {
    "kind": "aggregate",
    "by": "router",
    "confidence": 0.99,
    "probabilities": { "aggregate": 0.99, "unanswerable": 0.01 }
  },
  "query": {
    "sparql": "PREFIX p: <https://x.test/p/>\nPREFIX class: <https://x.test/class/>\nPREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>\nSELECT ?t ?l (COUNT(?s) AS ?n) WHERE { ?s a class:service ; p:team ?t . OPTIONAL {?t rdfs:label ?l} } GROUP BY ?t ?l",
    "entailment": "none"
  },
  "rationale": "I grouped the services by their p:team link and counted each group.",
  "attempts": 1,
  "steps": [
    { "n": 1, "tool": "sparql", "input": { "query": "PREFIX p: …", "entailment": "none", "as_of_commit_seq": null }, "ok": true, "rows": 2, "ms": 4 }
  ],
  "models": [
    { "role": "router", "provider": "typesafe", "model": "jev-latest", "ok": true, "ms": 300, "tokens": 757, "cost_micro_usd": 0 },
    { "role": "rewriter", "provider": "anthropic", "model": "claude-sonnet-5-5", "ok": true, "ms": 2987, "tokens": 4941, "cost_micro_usd": 5064 },
    { "role": "rewriter", "provider": "anthropic", "model": "claude-sonnet-5-5", "ok": true, "ms": 2382, "tokens": 5274, "cost_micro_usd": 4074 }
  ],
  "timings": { "ground_ms": 17, "route_ms": 301, "rewrite_ms": 5369, "run_ms": 4, "total_ms": 5717, "link_ms": 7, "anchor_ms": 0 }
}
```

The model ran one query, read its two rows, and answered. `result` holds
those rows. Each turn of the model is one entry of `models`.

The rows of `query` hold what the answer states. Some answers narrow the
rows: a cutoff, a condition, or the first few by a measure. The model then
runs a query that does the narrowing, and names that query. The server
checks the records the answer cites against the rows of its query. An
answer that cites other records, or skips rows, goes back to the model
once. An answer that names records of its rows by their labels but cites
none goes back once too. So the rows that your app shows beside the answer
match the text, and the records it names come with their IRIs.

| Field | Meaning |
| --- | --- |
| `answer` | `text`, the answer in plain words, and `citations`, the IRIs of the records it names. Each citation was in what the model read. The server removes any other. `chart` says how to draw the rows of `result`, when they make a chart. See [Draw the chart](#draw-the-chart). |
| `route` | `kind`, `confidence`, `by` and `probabilities`. `by` is `router` (Jev), `rewriter` (the model chose another route), or `caller` (your request fixed it). |
| `query` | The query whose rows hold the answer: `sparql`, with its `PREFIX` lines, and `entailment`. For a comparison, the compared query. |
| `result` | The rows of `query`: the response of `POST /v1/query/sparql-text`. It holds the rows in `results`, `row_page`, `snapshot`, the `search` report of a search query, and `trace_id`. Absent for a comparison. |
| `history` | For a question about the past: the commit the model read, and for a comparison the rows that differ. See [Questions about the past](#questions-about-the-past). |
| `steps` | The steps in order: `n`, `tool` (`sparql`, `find_entities`, `describe`, `commit_for_date` or `compare`), `input`, `ok`, `rows`, `error` and `ms`. |
| `rationale` | One sentence: which rows answer the question. |
| `error` | Why the model stopped before it answered. `answer` is then absent, and `result` holds the best rows it read. |
| `attempts` | The number of queries the model ran. |
| `grounding` | The description the models read: its `commit_seq`, and its counts of `classes`, `properties` and `embeddings`. `focus_classes` and `focus_properties` count the extra part for this question. `profile_version` is the version of your graph's profile the model read. `names` is the number of names the server looked up the question's names in. |
| `models` | One entry per model call, with its tokens and its cost in millionths of a dollar. |
| `timings` | Milliseconds per step: `ground_ms`, `route_ms`, `rewrite_ms` (the model's turns), `run_ms` (its steps) and `total_ms`. `link_ms` and `anchor_ms` run at the same time as `route_ms`. |
| `linked` | The names in the question that the server found in your graph. See [Names in the question](#names-in-the-question). |
| `anchors` | What the server read about each record of `anchor`. |

The query that holds the answer records an eval trace with the question as
its request. Label its rows with [evals](https://docs.littlebigbrain.com/sdks/http-api/#evaluate-answers),
and the good answers become test cases.

A question takes about 6 to 12 seconds and costs about 1 to 2 cents.

## Show each step

Your app can show each step while it happens: "Searching by meaning…", the
query, the rows. Send the header `Accept: text/event-stream`. The server then
sends server-sent events instead of one JSON body. Each event has a name and
JSON data:

| Event | Data | When |
| --- | --- | --- |
| `grounding` | `cached`, `age_ms`, `classes` | The graph description is ready. |
| `route` | `kind`, `confidence`, `by`, `probabilities` | The route is known. A second `route` comes before `answer` when the model chose another route. |
| `step` | `n`, `tool`, `input`, `ok`, `rows` | A step of the model ended. `input` is one line: the query without its `PREFIX` lines, the name, the words or the date. |
| `answer` | `text`, `citations`, `chart` | The answer, just before `done`. |
| `done` | The whole response | The last event. It is the same JSON as the response without the header. |
| `error` | `status`, `code`, `message` | The last event when the request failed. The same status and code as without the header. |

Ignore event names that you do not know. Later versions add events. A bad
request, the daily limit and an authentication error still answer with
their HTTP status and JSON error, before any event. When your app closes the
connection, the server stops the work and calls no more models.

`EventSource` cannot send a `POST`, so read the stream with `fetch`. This
loop shows each step in the browser:

```js
const response = await fetch(`${LBB_URL}/v1/query/ask?graph=services`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${LBB_API_KEY}`,
    "Content-Type": "application/json",
    Accept: "text/event-stream",
  },
  body: JSON.stringify({ question: "Which services score card payments?" }),
  signal: controller.signal, // controller.abort() stops the work on the server
});
if (!response.ok) throw new Error((await response.json()).error.message);

const show = {
  grounding: () => "Reading the graph…",
  route: (d) => (d.kind === "search" ? "Searching by meaning…" : `Route: ${d.kind}`),
  step: (d) => (d.tool === "sparql" ? `${d.input} (${d.rows ?? 0} rows)` : `${d.tool}: ${d.input}`),
  answer: (d) => d.text,
};
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  let end;
  while ((end = buffer.indexOf("\n\n")) >= 0) {
    const block = buffer.slice(0, end);
    buffer = buffer.slice(end + 2);
    const name = block.match(/^event: (.*)$/m)?.[1];
    const data = block.match(/^data: (.*)$/m)?.[1];
    if (!name || data === undefined) continue; // a ": keep-alive" comment
    const event = JSON.parse(data);
    if (name === "done") render(event); // the whole response
    else if (name === "error") showError(event.message);
    else if (show[name]) setStatus(show[name](event)); // skip unknown events
  }
}
```

The SDKs read the stream for you. In TypeScript,
`lbb.query.askStream(question, { signal })` returns the events as an async
iterable. In Python, `client.query.ask_stream(...)` is a generator of events.
Both raise the usual error on an `error` event. In MCP, `lbb_query` with
`mode: "question"` sends each step as a progress notification when your
client asks for progress.

## Draw the chart

`answer.chart` says how to draw the rows of `result`. `kind` is `bar`,
`line`, `scatter` or `table`. `x`, `y` and `series` name columns of the rows.
`series`, when present, splits the rows into groups.

The server checks each hint against the rows. Each named column exists. For
`bar` and `line`, `y` holds numbers. For `scatter`, `x` and `y` hold numbers.
A hint that does not fit is dropped, and the answer stays. There is no
`chart` when the rows make no chart, for example one number or a yes or no.
A `bar` whose `x` holds dates, years or year-months, in order, comes as a
`line`: a time series reads better as a line.

This example draws the answer above with Chart.js:

```ts
const { answer, chart, rows } = await lbb.query.ask(
  "How many services does each team run?",
);
showText(answer);

if (chart?.kind === "bar" || chart?.kind === "line") {
  new Chart(canvas, {
    type: chart.kind,
    data: {
      labels: rows.map((row) => row[chart.x!]),
      datasets: [{ label: chart.y!, data: rows.map((row) => Number(row[chart.y!])) }],
    },
  });
} else if (chart?.kind === "table") {
  showTable(rows);
}
```

In Python the hint is `result.chart`, a dict. In MCP it is `answer.chart`.

## Request fields

| Field | Meaning |
| --- | --- |
| `question` | The question, 1 to 4,000 characters. |
| `context` | Notes from your app for the model, at most 8,000 characters. See [Notes for the model](#notes-for-the-model). |
| `route` | Fixes the route. The decision model is not called. |
| `mode` | `answer` (default) runs the loop and answers. `route` returns the route only. See [Route only](#route-only). |
| `limit` | The rows each query returns: 1 to 1,000, default 100. The model reads at most 30 of them. |
| `as_of_commit_seq` | Read the graph at this commit. |
| `today` | Today's date as `YYYY-MM-DD`, for questions such as "the last 14 days". The default is the server's UTC date. |
| `include_grounding` | `true` returns the description the models read in `grounding.text`. |
| `anchor` | The IRIs of records the user picked in your app, at most 10. See [Records the user picked](#records-the-user-picked). |
| `timeline` | Dates that stand for your commits, at most 200: `[{date, as_of_commit_seq, label?}]`. See [Questions about the past](#questions-about-the-past). |

Any other field answers 400. `run` and `previous` belonged to the removed
one-shot mode: the loop always runs its queries, and corrects them itself.
Put what your app knows in `context`.

`?consistency=strong` on the URL makes the queries read the newest writes.
The default `eventual` reads the latest published commit. See
[Read consistency](https://docs.littlebigbrain.com/sdks/http-api/#read-consistency).

### Notes for the model

`context` tells the model what your data means: units, names to prefer, how
records connect. Send the same text with every question. The model provider
then reads it from its cache, which is faster.

```json
{
  "question": "Which tier 1 services does the payments team run?",
  "context": "Teams own services. Tier 1 services are the critical ones."
}
```

### Notes for every question

Notes that hold for every question of a graph belong in its profile. The
server keeps the profile with the graph and gives it to the model with each
question, so your app does not send the notes every time. A profile holds
`notes` and up to 20 worked `examples`: a question and the SPARQL query that
answers it. Write what the shape of the data does not say: which property
holds the current state, who "me" is, which records to leave out.

```sh
curl -X PUT "$LBB_URL/v1/query/rewrite/profile?graph=main" \
  -H "Authorization: Bearer $LBB_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "notes": "Teams own services. Tier 1 services are the critical ones.",
    "examples": [{
      "question": "Which services does the payments team run?",
      "sparql": "PREFIX p: <https://x.test/p/>\nSELECT ?s WHERE { ?s p:team <https://x.test/e/payments> }"
    }],
    "expected_version": 0
  }'
```

`GET /v1/query/rewrite/profile?graph=main` reads it back with its `version`.
The profile route keeps its old name. Each write adds 1 to the version. Send
the version you read as `expected_version`: when another write came first,
the server answers 409 `conflict` and stores nothing. `?dry_run=true` checks
a profile and stores nothing. The server parses each example query, and
accepts only `SELECT` and `ASK`. Empty notes and no examples clear the
profile. `context` still adds notes for one call.

The SDKs have `query.rewriteProfile()` and `query.setRewriteProfile()`
(TypeScript), and `query.rewrite_profile()` and `query.set_rewrite_profile()`
(Python). The MCP server has `lbb_configure` with `get_rewrite_profile` and
`set_rewrite_profile`.

### Route only

`mode: "route"` asks Jev alone, in about 0.3 seconds, and calls no other
model. Use it to show "searching by meaning…" at once, or to pick a template
of your own. When every route is below 0.5, ask the user to make the
question clearer.

```json
{ "question": "How many services are there?", "mode": "route" }
```

The response has `route`, and no `answer` or `query`. `attempts` is 0.

### Fix the route

`route` sets the kind of question. Jev is not called. Use it when your app
knows the kind, for example from a search box.

```json
{ "question": "Show me the services", "route": "aggregate" }
```

### Names in the question

Users name records in their own words: "TU Dresden" for "Technische
Universität Dresden", "Quelmann" for "Quellmann Fenstertechnik GmbH",
"Jurgen Weiss" for "Jürgen Weiß". Before the model starts, the server looks
up these names in your graph and gives the model the records they name. The
queries then use each record's IRI and do not search the names with text
filters. That is faster, and it does not match the wrong record. The model
can also look a name up itself, with its `find_entities` step.

The server reads the names of your records: `rdfs:label`, and the properties
whose name is a name, a label or a title, such as `name`, `legalName` or
`title`. It ignores case, accents, punctuation and legal forms such as
"GmbH". It also matches a name with one or two letters spelled differently,
and an acronym inside a longer name ("TU Dresden"). No model is called for
this step. It takes a few milliseconds.

Some words are not names, so the server does not link them:

- A word that asks, such as "Summarize", "Compare" or "Describe" at the
  start of a question.
- A capital that only starts a sentence ("Call notes of last week?").
- A lone acronym ("ICP"), a short word in lower case ("crm") or a short code
  ("Q3"), unless a record has exactly that name.

When a person and a record about the person have the same name (a CRM
contact and the Notion page with the contact's name), the person comes
first. People and organizations come before other records, and both come
before documents, notes, messages and mentions. The server reads this from
the class name ("Contact", "SourceDocument") and its superclasses
(`foaf:Person`, `schema:CreativeWork`).

The response lists the matches in `linked`:

```json
"linked": [
  {
    "text": "Quelmann",
    "iri": "https://demo.test/org/quellmann",
    "label": "Quellmann Fenstertechnik GmbH",
    "class": "https://demo.test/class/Organization",
    "score": 0.82,
    "by": "fuzzy"
  }
]
```

`text` is the words of the question. `by` is `exact`, `partial` (the words are
part of the name), `fuzzy` (spelled differently) or `acronym`. A name with
close matches has up to three entries with the same `text`, the best first.
Show "Did you mean Quellmann Fenstertechnik GmbH?" when `by` is `fuzzy`.

The server looks up names after it has read them once for your graph. After
a commit, it reads them again in the background. Until it is done, a
question links with the names it read before. A word that many records hold in their
names, such as a city in the names of its universities, is not linked.

### Records the user picked

When the user has a record open in your app, send its IRI in `anchor`. The
server reads the record's types, its label and its links. The model uses the
IRI and does not search for the record's name.

```json
{
  "question": "Who works on the open issues of this project?",
  "anchor": ["https://demo.test/project/offline-sync"]
}
```

`anchors` in the response tells what the server read about each IRI. An IRI
that is not in your graph gets a `note`, and the question still gets an
answer.

In the SDKs, pass `anchor` to `query.ask`: `{ anchor: [iri] }` in
TypeScript, `anchor=[iri]` in Python. `lbb_query mode=question` takes
`anchor` too. The answer of `query.ask` holds `linked` and `anchors`.

### Questions about the past

A question about a date reads the graph as it was then. The model calls its
`commit_for_date` step with the date, and runs its query at that commit.
Each commit records when it was written. For "Which findings were open on
18 June?" the server finds the last commit written by the end of 18 June
(UTC). `history.as_of_commit_seq` names that commit, and
`history.resolved_by` says how the server found it: `commit_time`,
`timeline`, or `request` (the model or your request named the commit).

Sometimes your commits stand for other dates. A demo loads its milestones in
one minute, or an import loads records of last year today. Then pass
`timeline`: the date each commit stands for.

```json
{
  "question": "Which findings were open on 18 June?",
  "timeline": [
    { "date": "2026-06-10", "as_of_commit_seq": 4, "label": "Scope check" },
    { "date": "2026-06-18", "as_of_commit_seq": 5, "label": "LV addendum" }
  ]
}
```

The server reads the latest point on or before the date: here commit 5.
`history.label` repeats its label.

The server never answers a question about a past date with the graph of
another commit. When it cannot place the date, the step fails and the model
reads why; it then says what the graph cannot tell:

- The date is before the first commit of the graph, or before the first
  point of `timeline`.
- An older server version wrote the commits, so they record no time, and
  the date falls between the creation of the graph and its last write.
  Pass a `timeline`.

A question that asks what changed ("Which deals moved stage since 1
June?") uses the `compare` step. It runs one query at the commit of the date
and at the later point (your `as_of_commit_seq`, else the latest commit),
and pairs the rows by the record. The response's `history` then has
`compare: true`, the earlier point, `history.key`, `history.added` (the
rows of records only the later point has), `history.removed` (the rows of
records only the earlier point has), and `history.changed` (the records
whose values differ, with their rows at both points). `history.totals`
counts each list. Each list shows at most 500 entries.
`history.truncated` is `true` when a point was not read whole, or a list
holds more than 500 entries. For more, use
[`POST /v1/query/compare`](#use-the-tools-in-your-own-agent). See
[Replay answers & history](https://docs.littlebigbrain.com/guides/time-travel-audit/).

In the SDKs, pass `timeline` to `query.ask`. The answer of `query.ask` holds
`history`. `lbb_query mode=question` takes `timeline` too.

## Use the tools in your own agent

Your app can run its own agent: your model writes queries, reads the rows
and writes again, in a loop of a few steps. Four routes give that agent what
only the server knows. None calls a model, and each is a read with the usual
API key. The loop of `POST /v1/query/ask` uses two of them as its
`describe` and `compare` steps.

| Route | Use it when |
| --- | --- |
| `POST /v1/query/names` | The question names a record: a person, a company, a project. It returns the IRI of each name with its other candidates, the one to prefer first. Put the IRI in the query; do not match the name with `CONTAINS`. `commit_seq` is the newest commit the name index holds. Right after a write, send `min_commit_seq` with the `commit_seq` of your write: the call waits a few seconds for the index to hold it, then answers `429 read_your_writes_pending`. Labels (`rdfs:label`, `skos:prefLabel`, `schema:name`) link in every class, however large. When `index_complete` is `false`, `index_gaps` lists the classes the name index did not read in full: another name property of such a class may not link. |
| `POST /v1/query/describe` | The agent needs classes or properties it has not seen. Send the `question`, or `classes` and `properties` as IRIs. Each class lists how many of its sampled instances hold each property, so the agent sees that a filter on a rare property returns few rows. A small class (30 instances at most, such as stages or statuses) lists its instances with their values. `text` holds the same description for a prompt. |
| `GET /v1/graph/commit-at?date=YYYY-MM-DD` | The question names a date. It returns the last commit written by the end of that day (or `moment=` an RFC 3339 time). Run the query with that `as_of_commit_seq`. Commits written before 6 October 2026 record no time: for an earlier date, `note` names the first commit that does, and you pass a `timeline` to `query.ask` instead. |
| `POST /v1/query/compare` | The question asks what changed. Send one `SELECT`, `before` and `after` (each a commit, a date or a moment; `after` defaults to the latest), and `key`: the variable of the record. It returns `added`, `removed` and `changed` with their `totals`, and pages each list with `next_cursor`. |

A loop in TypeScript, with your model in `decide`:

### TypeScript

```ts
import { LbbClient } from "@littlebigbrain/client";

const lbb = new LbbClient({
  baseUrl: process.env.LBB_URL!,
  apiKey: process.env.LBB_API_KEY!,
  graph: "crm",
});
const question = "Which of Mara Lindqvist's deals moved stage since 1 June?";

// 1. The records the question names, and the parts of the graph it needs.
const names = await lbb.query.names({ text: question });
const lindqvist = names.candidates.find((c) => c.text === "Mara Lindqvist")?.iri;
const schema = await lbb.query.describe({ question });

// 2. Your model writes the query from schema.text and the IRIs.
const sparql = await decide(question, schema.text, { lindqvist });

// 3. What changed: the query at the date's commit and now, paired by deal.
const diff = await lbb.query.compare({
  query: sparql,
  before: { date: "2026-06-01" },
  key: ["deal"],
});
for (const change of diff.changed) {
  console.log(change.key.deal.value, change.before, "->", change.after);
}
console.log(diff.totals, diff.truncated);
```

### Python

```python
import os

from lbb import LbbClient

lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="crm")
question = "Which of Mara Lindqvist's deals moved stage since 1 June?"

names = lbb.query.names(question)
lindqvist = next(c["iri"] for c in names["candidates"] if c["text"] == "Mara Lindqvist")
schema = lbb.query.describe(question=question)

sparql = decide(question, schema["text"], {"lindqvist": lindqvist})

diff = lbb.query.compare(sparql, before={"date": "2026-06-01"}, key=["deal"])
for change in diff["changed"]:
    print(change["key"]["deal"]["value"], change["before"], "->", change["after"])
print(diff["totals"], diff.get("truncated", False))
```

`compare` reads up to `max_rows` rows at each point (default 20,000, at most
50,000), page by page, and stops early when the read deadline is near.
`truncated: true` and a note say when a point was not read whole; each
point's `rows` and `total` say how much. Pass `next_cursor` back as `cursor`,
with the same request, for the next entries of each list; the cursor keeps
both commits. Leave `LIMIT` out of the query.

In the MCP server these are `lbb_query` modes: `names`, `describe`,
`commit_at` and `compare`.

### Test your agent's answers

Your agent's answers can become test cases, and the router gets a score on
your questions.

1. Route the question with `mode: "route"`. Give the route to your model as
   a hint.
2. Run the query that holds the answer with `request`, the user's words,
   and `route`, the `route` you got. The eval trace keeps both. Send
   `request` with that query only, so the other queries of your loop record
   no trace.
3. Label the answer. `valid: true` makes the query the golden query and
   the route the golden's route. `route` in the label names another route.
4. To test your agent again, read the goldens (`GET /v1/evals/goldens`),
   ask each question with your agent, and send the answers to
   `POST /v1/evals/run/answers`:

```json
{
  "runner": "my-agent",
  "answers": [
    {
      "golden": "4f0c2a9e1b7d3c5a8e6f1d2b",
      "sparql": "SELECT ?s ?label WHERE { … }",
      "entailment": "subclass",
      "route": { "kind": "lookup", "confidence": 0.91, "by": "router" },
      "reply": { "text": "Two services: risk and ledger." }
    },
    { "golden": "9a1e7c3b5d2f4a6c8e0b1d3f", "error": "the model timed out" }
  ]
}
```

The server runs each query and checks it as it checks an answer of
`query.ask`: the type of the query, its rows against the golden query, and
the judged rows. Leave out `sparql` when your agent found the question
unanswerable. `route_check` compares the router's pick with the golden's
route. It scores the router and does not change the verdict. The runs of
your agent are a series of their own: `GET /v1/evals/results?runner=my-agent`.
In the SDKs this is `evals.runAnswers` (TypeScript) and
`evals.run_answers` (Python); in the MCP server, `lbb_evals
action=run_answers`.

## Limits

- Each server answers at most 500 questions per environment per UTC day.
  Past that, the route returns 429 `rewrite_limit` until the next UTC day. A
  request with a bad body does not count.
- A question has at most 4,000 characters, and `context` at most 8,000.
- A profile has at most 8,000 characters of notes and 20 examples. An
  example query has at most 4,000 characters, and all examples together at
  most 32,000.
- A request stops at its 60-second read deadline. Other reads stop at 25
  seconds.
- The model takes at most 4 rounds of steps and 12 steps. A query runs at
  most 8 seconds, a comparison 12. The answer cites at most 20 records.
- A query returns at most 1,000 rows; the model reads at most 30.
- A query reads one commit, like every other query. A comparison reads two,
  one after the other.
- `timeline` has at most 200 points. One lookup of the commit of a date
  reads at most 512 published generations in 8 seconds. The server keeps
  what it read, so the next lookup goes on from there.

## Errors

| HTTP | `code` | Meaning |
| --- | --- | --- |
| 400 | `invalid_ask_request` | A field is outside its limits, or it belongs to the removed one-shot mode (`run`, `previous`, `mode: "rewrite"`). `param` names it. |
| 400 | `invalid_request` | The body is not valid JSON, or it has another unknown field. The message lists the fields. |
| 404 | `not_found` | `POST /v1/query/rewrite`, removed on 2026-10-05. `did_you_mean` names `/v1/query/ask`. |
| 429 | `rewrite_limit` | The environment asked its questions of the day on this server. Retry the next UTC day. |
| 503 | `rewrite_model_unavailable` | The model did not answer, or the server has no model. The server already tried a failed model call once more when time allowed. Retry later. |
| 400 | `invalid_rewrite_profile` | A profile is outside its limits, or an example query does not parse or is not a `SELECT` or an `ASK`. `param` names `notes` or `examples`. |
| 409 | `conflict` | A profile write named an `expected_version` that is no longer stored. Read the profile again. |

A step that fails is no error of the route. The model reads the error and
tries again. A model call that fails on the way (a network error, a
timeout, a busy or failed provider) is sent once more when enough time is
left; `models` lists both calls. When the model stops before it answers,
the status is still 200, `answer` is absent, and `error` says why.

## What leaves little big brain

To answer, the models read a description of your graph and the rows of their
queries. These texts go to the model providers: TypeSafe for Jev, and
Anthropic for Claude. The texts that leave little big brain are:

- your question and `context`;
- the records the server found for the names in the question, and the
  records of `anchor`: their IRIs, labels and types, and for `anchor` their
  links with up to three linked records each;
- your graph's profile (to Anthropic only);
- class and property names, their labels and comments, and their counts;
- short example values from up to 100 records of each large class, at most
  48 characters each;
- the OWL and RDFS statements of your schema;
- the names and fields of your embeddings;
- what the model's steps return (to Anthropic only): the row count and up
  to 30 rows of each query (values cut at 200 characters), the records
  `find_entities` finds, the descriptions `describe` returns (with the
  instances and values of small classes), the commits of dates, and the
  first 30 entries of each list of a comparison.

The texts of an embedding already go to the embedding model in the same way.
