# little big brain documentation > Full Markdown context for the public documentation hosted at docs.littlebigbrain.com. Generated from `docs-site/src/content/docs/`. Use `/llms.txt` for a shorter curated index. ## Table of contents - [little big brain documentation](https://docs.littlebigbrain.com/): Find facts by meaning, follow graph paths to the evidence, and replay any answer. Start with the quickstart, or map what you know from vector stores. - [Quickstart: find it by meaning, prove it by path](https://docs.littlebigbrain.com/start/quickstart/): Write a few records as JSON, find one by meaning, and follow its links to an answer that names the commit it read. About ten minutes. No RDF or graph experience needed. - [Build a workflow](https://docs.littlebigbrain.com/start/first-workflow/): Test LBB's values.extract template on two records, save your answers as a dataset, then create the workflow and read what it writes. - [What is little big brain?](https://docs.littlebigbrain.com/start/overview/): little big brain is a search platform for AI applications. Vector search finds facts by meaning, a graph query follows them to the evidence, and every answer names the commit it read. - [Core concepts](https://docs.littlebigbrain.com/start/concepts/): The little big brain model for developers who know vector stores. Records, types, links, commits, search by meaning, graph queries, and the RDF words you will meet. - [Glossary](https://docs.littlebigbrain.com/start/glossary/): Short definitions of the words for workflows, quality and review in little big brain, plus the engine words under Details. - [Load your data](https://docs.littlebigbrain.com/start/load-data/): Load a database and your documents into one graph as JSON, in four calls. Pick a use case, and every example on the page follows it. - [How search works](https://docs.littlebigbrain.com/start/platform/): Find a starting fact by meaning, follow graph links to the evidence, and replay the answer at the commit it read. One support question, step by step. - [Use cases](https://docs.littlebigbrain.com/start/use-cases/): Six agent decisions, each with the records to store, the query that answers it, and what to keep so the decision can be replayed. - [Vector RAG, graph RAG, SQL and full-text search compared](https://docs.littlebigbrain.com/start/database-comparison/): Compare vector, graph, SQL and full-text retrieval for AI agents on one connected-facts question, and see how little big brain combines search by meaning with graph paths. - [Choosing an SDK](https://docs.littlebigbrain.com/sdks/overview/): All the ways to talk to little big brain (TypeScript, Python, MCP, and raw HTTP) and when to use each. - [TypeScript](https://docs.littlebigbrain.com/sdks/typescript/): The TypeScript client for little big brain. Declare types and links, write records, search by meaning, follow links with SPARQL and replay each answer at its commit. - [Python — lbb](https://docs.littlebigbrain.com/sdks/python/): The Python client for little big brain. Declare types and links, write records, search by meaning, follow links with SPARQL and replay each answer at its commit. Sync and async, with dict or typed responses. - [MCP server for AI agents](https://docs.littlebigbrain.com/sdks/mcp/): Give an agent tools to search your graph by meaning, follow links with SPARQL, cite the commit it read, and write checked records. Local stdio or hosted OAuth. - [HTTP API & SPARQL protocol](https://docs.littlebigbrain.com/sdks/http-api/): The HTTP routes every SDK wraps. Auth, idempotency, errors, writes, SPARQL reads, search by meaning, schema, and the native SPARQL 1.1 Protocol endpoint. - [Add links with an LLM](https://docs.littlebigbrain.com/guides/add-links-with-an-llm/): Some facts live only in your documents. An LLM reads each section and writes them as links. Your code drops links to unknown records, a dry run checks the rest, then you write them. - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): Declare a schema, write records, follow a chain of links, validate writes, and replay a decision, all from JSON and the SDKs. No RDF knowledge needed. - [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/): Declare an embedding on a type of record, let the platform keep its vectors current, and search by meaning with filters by type and by link, or inside a SPARQL query. Each hit is checked against the graph. - [Answer questions in plain words](https://docs.littlebigbrain.com/guides/answer-questions/): Send a question in plain words. The server describes your graph, picks the kind of question, runs queries in a short loop, reads the rows, and answers with the records it cites. - [Query with SPARQL and validate writes with SHACL](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): Count, filter and follow links between records with SPARQL. Publish SHACL rules for your records, read the conformance report, and reject writes that break a rule. - [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/): Ask for a query profile to see the time, the reads and the join order behind one SPARQL answer. Read the statistics the planner uses for a graph. - [Replay answers and history](https://docs.littlebigbrain.com/guides/time-travel-audit/): Store the commit behind an agent's answer, replay its query with as_of_commit_seq, and see when an answer changed. Covers pinned reads, validation state, valid time and retention. - [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/): Let a query also return the facts that follow from your schema, such as "an active study site is a study site". You choose the rules per query with the entailment option. - [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/): Load Turtle, N-Triples, N-Quads or TriG from another triple store or a public dataset, then query it with SPARQL. Covers formats, parameters, limits, large loads and validation. - [Authentication & endpoints](https://docs.littlebigbrain.com/start/authentication/): Environments, per-environment API keys, bearer auth, base URLs, and how the hosted MCP OAuth flow differs from a static key. - [Endpoints & base URLs](https://docs.littlebigbrain.com/start/endpoints/): Use the tenant-plus-stack endpoint copied from the console for every hosted data-plane request. - [Usage & billing](https://docs.littlebigbrain.com/start/billing/): Current little big brain Cloud pricing starts at €19/$21 per month and €0.45/$0.49 per GiB-month. Understand storage, included queries, payments, and retained plan terms. - [Changelog](https://docs.littlebigbrain.com/changelog/): Product updates for little big brain Cloud and the database engine. - [Cloud changelog](https://docs.littlebigbrain.com/changelog/cloud/): Updates to the hosted console, Explorer, accounts, access, and billing. - [Engine changelog](https://docs.littlebigbrain.com/changelog/engine/): Updates to queries, data import, validation, reasoning, performance, and SDKs. - [Check documents against each other (experimental)](https://docs.littlebigbrain.com/guides/check-documents-against-each-other/): Upload an order, a delivery note and an invoice. SHACL rules compare their values, and each difference becomes a review item with the quotes of both documents. - [Check model quality (experimental)](https://docs.littlebigbrain.com/guides/check-model-quality/): See how well the models LBB runs for a graph do their jobs, correct the judge, test other models, tune search, and calibrate thresholds and workflow gates. - [Datasets of examples (experimental)](https://docs.littlebigbrain.com/guides/datasets/): Keep examples with the values a correct run writes. Save them from a workflow test, add your own, export them as a template's samples, and delete them. - [Offer Notion, GitHub and Slack in your product (experimental)](https://docs.littlebigbrain.com/guides/developer-owned-integrations/): Register your own provider apps and let each customer authorize selected resources without creating an LBB account. - [From documents to a graph (experimental)](https://docs.littlebigbrain.com/guides/documents-to-graph/): Upload PDF files, connect the mentions of one thing across them, fill values from text, and read each link with its box on the page. - [Compare two versions of a workflow (experimental)](https://docs.littlebigbrain.com/guides/experiments/): Run a workflow as it is and with one change on the same examples, grade both against the right values, and see which is better and by how much. - [Integrations for your customers (experimental)](https://docs.littlebigbrain.com/guides/integrations-for-your-customers/): Connect each customer's sources to a graph of their own, using your backend and your own Notion, GitHub or Slack app. - [Integrations (experimental)](https://docs.littlebigbrain.com/guides/integrations/): Sync customer sources into a graph on durable workflows, including opt-in developer-owned Notion, GitHub and Slack apps. - [Ontology starters (experimental)](https://docs.littlebigbrain.com/guides/ontology-starters/): Start a graph from a versioned base ontology for CRM, documents or work management. - [Ontology suggestions (experimental)](https://docs.littlebigbrain.com/guides/ontology-suggestions/): Review ontology changes from integrations, agents and people before they reach your graph. - [Dedicated deployments](https://docs.littlebigbrain.com/guides/pro-deployments/): Enterprise runs Little Big Brain on dedicated infrastructure, hosted by us in Europe or in your AWS account, Google Cloud project or Azure subscription. - [Run a worker in Python (experimental)](https://docs.littlebigbrain.com/guides/python-worker/): Durable workflows in Python with lbb.workflows, the same rules as the TypeScript worker. - [Read an RDF record](https://docs.littlebigbrain.com/guides/records/): Full IRIs, exact RDF values, independent claims, and snapshot-pinned record reads across HTTP, SDKs and MCP. - [Ask a person: review in workflows (experimental)](https://docs.littlebigbrain.com/guides/review-in-workflows/): Send a question from a workflow to a person, and use the answer in the graph and in the workflow. - [Starter workflows (experimental)](https://docs.littlebigbrain.com/guides/starter-workflows/): Create a workflow from a starter workflow on a class, and the platform runs its work on every new or changed instance. - [Suggest ontology changes from text (experimental)](https://docs.littlebigbrain.com/guides/suggest-ontology-changes-from-text/): Let the platform read your transcripts or documents and propose classes, properties and relations for review. - [Test a workflow before it writes (experimental)](https://docs.littlebigbrain.com/guides/test-a-workflow/): Run a values workflow on a few instances, read each value with its quote, answer each one, and save the answers as a dataset. Nothing is written to the graph. - [Settings history of a workflow (experimental)](https://docs.littlebigbrain.com/guides/workflow-settings-history/): See who changed a workflow's settings and when, compare two versions, and go back to earlier settings. Each run records the settings it ran on. - [Workflow templates (experimental)](https://docs.littlebigbrain.com/guides/workflow-templates/): Run the code of a starter workflow in your own worker, with LBB's models as activities, and change it. - [Workflows (experimental)](https://docs.littlebigbrain.com/guides/workflows/): Saved state, durable messages and automatic history rollover, stored in object storage. - [Error codes](https://docs.littlebigbrain.com/sdks/errors/): Every error code the data plane sends, what it means, and how to fix the request or when to retry it. - [How to model your app](https://docs.littlebigbrain.com/start/model-your-app/): Start with a graph and a token, then add optional namespaces, delegated reads and shared reference datasets. ## little big brain documentation URL: https://docs.littlebigbrain.com/ Source: docs-site/src/content/docs/index.mdx Description: Find facts by meaning, follow graph paths to the evidence, and replay any answer. Start with the quickstart, or map what you know from vector stores. little big brain is a search platform for AI applications. Vector search finds facts by meaning. A graph query follows them to the evidence. Each answer names the commit it read, so you can replay it. You write records as JSON. You need no RDF or graph database experience to start. - [Quickstart](https://docs.littlebigbrain.com/start/quickstart/): Write a few records, find one by meaning and follow its links to a cited answer. About ten minutes. - [Load your data](https://docs.littlebigbrain.com/start/load-data/): Load a database and your documents as JSON, in four calls. Examples for five use cases. - [Connect an agent](https://docs.littlebigbrain.com/sdks/mcp/): Give an agent tools to search, query and write through MCP. - [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/): Declare embeddings on your types and filter hits by type and by link. ### From a question to a cited answer | Step | What it does | Go deeper | | --- | --- | --- | | Find it by meaning | Vector search finds a starting record, even when the question uses other words. | [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) | | Prove it by path | A graph query follows the stored links from that record to the facts behind the answer. | [How search works](https://docs.littlebigbrain.com/start/platform/) | | Keep the evidence | The response names its commit. Save it with the answer and replay the query later. | [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) | The [quickstart](https://docs.littlebigbrain.com/start/quickstart/) runs all three steps on one example: **which release fixed the login bug?** ### Start from what you know - **Vector stores.** Collections, documents and metadata map to graphs, records and fields. See [core concepts](https://docs.littlebigbrain.com/start/concepts/#from-a-vector-store). - **Graph databases.** SPARQL patterns read like Cypher `MATCH` clauses. See [the Cypher mapping](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql). - **SQL.** Tables and joins map to types and links. See [coming from SQL](https://docs.littlebigbrain.com/start/database-comparison/#if-you-use-a-relational-database). - **RDF.** Load Turtle or N-Triples as it is. See [load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/). ### What you get **Managed embeddings.** Choose the fields to embed. The platform embeds each record and keeps its vector current. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). **Checked writes.** Declare rules for your records. A write that breaks a rule is refused with a report of what to fix. [Validate writes](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#published-shacl-conformance). **Replayable answers.** Past commits stay readable. Run a query against the data an agent read last week. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/). **Open standards.** Queries use SPARQL, rules use SHACL, and data is RDF underneath. Standard tools connect directly. [Core concepts](https://docs.littlebigbrain.com/start/concepts/#rdf-under-the-hood). ### Connect your application - [TypeScript](https://docs.littlebigbrain.com/sdks/typescript/): Node.js, API routes and edge functions. - [Python](https://docs.littlebigbrain.com/sdks/python/): Scripts, services and notebooks. - [MCP for agents](https://docs.littlebigbrain.com/sdks/mcp/): Search, query and write tools for any MCP client. - [HTTP & SPARQL](https://docs.littlebigbrain.com/sdks/http-api/): Any language, or an existing SPARQL client. ### Guides - [Load your data](https://docs.littlebigbrain.com/start/load-data/): a database and your documents, in five use cases. - [Add links with an LLM](https://docs.littlebigbrain.com/guides/add-links-with-an-llm/): join your database records and your document sections. - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): a schema, checked writes, a chain of links and a replayed decision. - [Use cases](https://docs.littlebigbrain.com/start/use-cases/): six agent decisions and the query behind each one. - [Query with SPARQL and validate with SHACL](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): filters, aggregates, paths and rules. - [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/): answers that use your type hierarchy. - [Ontology starters (experimental)](https://docs.littlebigbrain.com/guides/ontology-starters/): shared types for CRM, documents and work management. For setup, see [API keys](https://docs.littlebigbrain.com/start/authentication/) and [stack endpoints](https://docs.littlebigbrain.com/start/endpoints/). For costs, see [usage and billing](https://docs.littlebigbrain.com/start/billing/) or the [pricing calculator](https://littlebigbrain.com/pricing/). ## Quickstart: find it by meaning, prove it by path URL: https://docs.littlebigbrain.com/start/quickstart/ Source: docs-site/src/content/docs/start/quickstart.mdx Description: Write a few records as JSON, find one by meaning, and follow its links to an answer that names the commit it read. About ten minutes. No RDF or graph experience needed. This quickstart answers one question: **which release fixed the login bug?** You write a few support tickets, bugs and releases as JSON. Search by meaning finds the right ticket, although the question uses other words. A graph query then follows the ticket's links to the release. Both reads name the same commit, so you can check the answer later. ```text "users cannot sign in after the upgrade" │ search by meaning ▼ ticket 4821: login fails after 3.1 └─ REPORTED_AS → bug AUTH-112: session token expiry └─ FIXED_IN → Release 3.2 ``` It takes about ten minutes. You need no RDF or graph database experience. To start from your own data, see [Load your data](https://docs.littlebigbrain.com/start/load-data/). It loads a database and documents, with examples for five use cases. ### 1. Get your credentials Open the [console](https://cloud.littlebigbrain.com). If you are signed out, it opens the WorkOS sign-in page automatically. Choose **Sign up** there if you need an account. Once signed in, create a stack and open **Connect**. Copy the **complete endpoint** and a stack API key. Then set them in your terminal: ```sh export LBB_URL="https://" export LBB_API_KEY="" ``` Use the exact endpoint from Connect. It looks like `https://--.db.eu.littlebigbrain.com`. Keep the key on your server or your machine. Never put it in a browser bundle. Install a client, or use cURL. The cURL steps also use [jq](https://jqlang.org) to read fields from JSON responses. ### TypeScript ```sh npm install @littlebigbrain/client ``` Create `quickstart.mts`. Add the snippet of each step to the end of the file. Run it with `npx tsx quickstart.mts` after any step. Each call is safe to repeat. ### Python ```sh pip install littlebigbrain ``` Create `quickstart.py`. Add the snippet of each step to the end of the file. Run it with `python3 quickstart.py` after any step. Each call is safe to repeat. ### cURL Run each block in the terminal where you set your credentials. Each call is safe to repeat. ### 2. Describe your data A graph accepts the record types, links and fields you declare. This works like a table schema. A write that does not fit is refused, so bad data does not get in. | Name | Kind | Meaning | | --- | --- | --- | | `Ticket`, `Bug`, `Release` | Record types | The three kinds of records. | | `REPORTED_AS` | Link | A ticket is reported as a bug. | | `FIXED_IN` | Link | A bug is fixed in a release. | | `description` | Text field | Free text on a record. | The graph `support` is created by the first call. A graph is one dataset in your stack, like a database or an index. [Load your data](https://docs.littlebigbrain.com/start/load-data/) explains the schema and the other ways to load data. ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); await lbb.ontology.define({ format: "spec", merge_default: false, source: JSON.stringify({ entity_types: ["Ticket", "Bug", "Release"], relation_types: [ { name: "REPORTED_AS", source: "Ticket", target: "Bug" }, { name: "FIXED_IN", source: "Bug", target: "Release" }, ], }), }); await lbb.ontology.evolve({ ops: [{ op: "add_property", name: "description", value_type: "text" }], }); ``` ### Python ```python import json import os import time from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") lbb.ontology.define({ "format": "spec", "merge_default": False, "source": json.dumps({ "entity_types": ["Ticket", "Bug", "Release"], "relation_types": [ {"name": "REPORTED_AS", "source": "Ticket", "target": "Bug"}, {"name": "FIXED_IN", "source": "Bug", "target": "Release"}, ], }), }) lbb.ontology.evolve({ "ops": [{"op": "add_property", "name": "description", "value_type": "text"}], }) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/define?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"format": "spec", "merge_default": false, "source": "{\"entity_types\": [\"Ticket\", \"Bug\", \"Release\"], \"relation_types\": [{\"name\": \"REPORTED_AS\", \"source\": \"Ticket\", \"target\": \"Bug\"}, {\"name\": \"FIXED_IN\", \"source\": \"Bug\", \"target\": \"Release\"}]}"}' curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"ops": [{"op": "add_property", "name": "description", "value_type": "text"}]}' ``` ### 3. Write records A write is a **commit**. It holds links between records (`triplets`) and field values (`entity_properties`). A record is its type plus a `key`, and it has a `name`. Each link can carry `evidence`, the reason it was recorded. After the write, wait until it is published. Search reads published data. This usually takes a second or two. ### TypeScript ```ts const login = { type: "Ticket", key: "4821", name: "login fails after 3.1" }; const invoice = { type: "Ticket", key: "4822", name: "invoice PDF is blank" }; const csvExport = { type: "Ticket", key: "4823", name: "CSV export times out" }; const auth = { type: "Bug", key: "AUTH-112", name: "session token expiry" }; const pdf = { type: "Bug", key: "BILL-7", name: "PDF fonts missing" }; const slowExport = { type: "Bug", key: "DATA-58", name: "export runs on one thread" }; const r32 = { type: "Release", key: "3.2", name: "Release 3.2" }; const r311 = { type: "Release", key: "3.1.1", name: "Release 3.1.1" }; const written = await lbb.graph("support").facts.create( { triplets: [ { source: login, relation: "REPORTED_AS", target: auth, evidence: "Support triage, 2 Sep" }, { source: invoice, relation: "REPORTED_AS", target: pdf, evidence: "Support triage, 3 Sep" }, { source: csvExport, relation: "REPORTED_AS", target: slowExport, evidence: "Support triage, 4 Sep" }, { source: auth, relation: "FIXED_IN", target: r32, evidence: "Release notes 3.2" }, { source: pdf, relation: "FIXED_IN", target: r311, evidence: "Release notes 3.1.1" }, ], entity_properties: [ { ...login, properties: { description: "Since 3.1, people are sent back to the login screen right after they type their password." } }, { ...invoice, properties: { description: "Downloaded invoices open as empty pages." } }, { ...csvExport, properties: { description: "Exports with more than 10,000 rows stop after 30 seconds." } }, ], }, { idempotencyKey: "quickstart-support-1" }, ); await lbb.waitForPublished(written.commit_seq); console.log("commit", written.commit_seq); ``` ### Python ```python login = {"type": "Ticket", "key": "4821", "name": "login fails after 3.1"} invoice = {"type": "Ticket", "key": "4822", "name": "invoice PDF is blank"} csv_export = {"type": "Ticket", "key": "4823", "name": "CSV export times out"} auth = {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"} pdf = {"type": "Bug", "key": "BILL-7", "name": "PDF fonts missing"} slow_export = {"type": "Bug", "key": "DATA-58", "name": "export runs on one thread"} r32 = {"type": "Release", "key": "3.2", "name": "Release 3.2"} r311 = {"type": "Release", "key": "3.1.1", "name": "Release 3.1.1"} written = lbb.graph("support").facts.create({ "triplets": [ {"source": login, "relation": "REPORTED_AS", "target": auth, "evidence": "Support triage, 2 Sep"}, {"source": invoice, "relation": "REPORTED_AS", "target": pdf, "evidence": "Support triage, 3 Sep"}, {"source": csv_export, "relation": "REPORTED_AS", "target": slow_export, "evidence": "Support triage, 4 Sep"}, {"source": auth, "relation": "FIXED_IN", "target": r32, "evidence": "Release notes 3.2"}, {"source": pdf, "relation": "FIXED_IN", "target": r311, "evidence": "Release notes 3.1.1"}, ], "entity_properties": [ {**login, "properties": {"description": "Since 3.1, people are sent back to the login screen right after they type their password."}}, {**invoice, "properties": {"description": "Downloaded invoices open as empty pages."}}, {**csv_export, "properties": {"description": "Exports with more than 10,000 rows stop after 30 seconds."}}, ], }, idempotency_key="quickstart-support-1") lbb.wait_for_published(written["commit_seq"]) print("commit", written["commit_seq"]) ``` ### cURL Save the records as `records.json`: ```json { "triplets": [ {"source": {"type": "Ticket", "key": "4821", "name": "login fails after 3.1"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"}, "evidence": "Support triage, 2 Sep"}, {"source": {"type": "Ticket", "key": "4822", "name": "invoice PDF is blank"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "BILL-7", "name": "PDF fonts missing"}, "evidence": "Support triage, 3 Sep"}, {"source": {"type": "Ticket", "key": "4823", "name": "CSV export times out"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "DATA-58", "name": "export runs on one thread"}, "evidence": "Support triage, 4 Sep"}, {"source": {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"}, "relation": "FIXED_IN", "target": {"type": "Release", "key": "3.2", "name": "Release 3.2"}, "evidence": "Release notes 3.2"}, {"source": {"type": "Bug", "key": "BILL-7", "name": "PDF fonts missing"}, "relation": "FIXED_IN", "target": {"type": "Release", "key": "3.1.1", "name": "Release 3.1.1"}, "evidence": "Release notes 3.1.1"} ], "entity_properties": [ {"type": "Ticket", "key": "4821", "name": "login fails after 3.1", "properties": {"description": "Since 3.1, people are sent back to the login screen right after they type their password."}}, {"type": "Ticket", "key": "4822", "name": "invoice PDF is blank", "properties": {"description": "Downloaded invoices open as empty pages."}}, {"type": "Ticket", "key": "4823", "name": "CSV export times out", "properties": {"description": "Exports with more than 10,000 rows stop after 30 seconds."}} ] } ``` Commit it, then wait until the graph reports `"state": "current"`: ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-support-1" \ --data-binary @records.json until curl -sS "$LBB_URL/v1/graph/publication-status?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done ``` The response reports `commit_seq`, the number of this commit. On a new graph it is `1`. If you change the records, change the idempotency key too. A reused key returns the first write with `replayed: true` and stores nothing new. ### 4. Turn on search for tickets Declare an **embedding** on the `Ticket` type and name the fields to embed. The platform builds one text per ticket, embeds it and stores the vectors. When a ticket changes, its vector is updated. You run no embedding model and no vector store. `https://littlebigbrain.com/class/ticket` is the global name of the `Ticket` type. Each type you declare gets one: its name in lowercase after `https://littlebigbrain.com/class/`. `label` is the record's `name`. The first build runs in the background. Wait until the embedding reports `serving`. ### TypeScript ```ts const TICKET = "https://littlebigbrain.com/class/ticket"; await lbb.embeddings.declare({ class: TICKET, from: ["label", "description"] }); while (!(await lbb.embeddings.get("ticket")).serving) { await new Promise((resolve) => setTimeout(resolve, 2_000)); } ``` ### Python ```python TICKET = "https://littlebigbrain.com/class/ticket" lbb.embeddings.declare(TICKET, from_=["label", "description"]) while not lbb.embeddings.get("ticket").get("serving"): time.sleep(2) ``` ### cURL ```sh curl --fail-with-body -sS -X PUT "$LBB_URL/v1/embeddings?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"class": "https://littlebigbrain.com/class/ticket", "from": ["label", "description"]}' until curl -sS "$LBB_URL/v1/embeddings?graph=support&name=ticket" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"serving": *{'; do sleep 2; done ``` ### 5. Search by meaning Ask the question in your own words. The question says "sign in" and "upgrade". The ticket says "login" and "3.1". ### TypeScript ```ts const found = await lbb.embeddings.search({ text: "users cannot sign in after the upgrade", top_k: 3, }); for (const hit of found.hits) console.log(hit.label); ``` ### Python ```python found = lbb.embeddings.search("users cannot sign in after the upgrade", top_k=3) for hit in found["hits"]: print(hit["label"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/search?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"text": "users cannot sign in after the upgrade", "top_k": 3}' \ -o found.json jq -r '.hits[].label' found.json ``` The login ticket comes first: ```text login fails after 3.1 ``` The other two tickets follow, in an order that depends on the model. Each hit also has a `score` and an `iri`, the global name of that record. The response field `served_at_seq` is the commit the search read. ### 6. Follow the path to the answer Similarity found a starting point. It does not prove which release fixed the bug. The stored links do. This query starts at the ticket the search found and follows two links. The query language is SPARQL, the standard for graph queries. Each line in the braces is a pattern: a record, a link, then another record. Names that start with `?` are variables. The query returns the values that fit all patterns at once. In Cypher, the same path reads `(t)-[:REPORTED_AS]->(b)-[:FIXED_IN]->(r)`. - `dr:reported_as` is your `REPORTED_AS` link. `dr:` is short for `https://littlebigbrain.com/r/`, where the links you declare live. - `rdfs:label` is the `name` of a record. - `as_of_commit_seq` runs the query at the commit the search read. Search and path then agree on one version of the data. ### TypeScript ```ts const ticket = found.hits[0].iri; const query = ` PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE { <${ticket}> dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . }`; const { rows, snapshot } = await lbb.sparqlRows({ query, as_of_commit_seq: found.served_at_seq }); console.log(rows[0], "read at commit", snapshot?.served_at_seq); ``` ### Python ```python ticket = found["hits"][0]["iri"] query = f""" PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE {{ <{ticket}> dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . }}""" path = lbb.sparql(query, as_of_commit_seq=found["served_at_seq"]) print(path.rows()[0], "read at commit", path.snapshot["served_at_seq"]) ``` ### cURL ```sh TICKET=$(jq -r '.hits[0].iri' found.json) SEQ=$(jq -r '.served_at_seq' found.json) curl --fail-with-body -sS -G "$LBB_URL/sparql?graph=support&as_of_commit_seq=$SEQ" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Accept: application/sparql-results+json" \ --data-urlencode "query= PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE { <$TICKET> dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . }" ``` The TypeScript example prints this on a new graph: ```text { bug: 'session token expiry', release: 'Release 3.2' } read at commit 1 ``` Python prints the same row. cURL returns it in SPARQL Results JSON, under `results.bindings`. ### 7. Keep the evidence Your agent can now answer "Release 3.2 fixed it" and show why. Store these with the answer: - the question and the search hits, - the query and the rows it returned, - the commit number, `served_at_seq`. Later writes do not change that commit. Run the same query with the same `as_of_commit_seq` later. It returns the same rows while the commit is retained. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) covers retention and history. ### If something goes wrong | What you see | What to do | | --- | --- | | `401` | The API key is missing, wrong or revoked. Export a current key in the same terminal. | | `stack_endpoint_mismatch` or `stack_endpoint_required` | Copy the complete endpoint from the same stack's Connect view. | | `400` with `no predicate named description` when you declare the embedding | The write is not published yet. Wait for it, then declare again. | | `409 embedding_not_ready` | The first build is still running. Wait until the embedding reports `serving`. | | Edited records do not appear, and the commit reports `replayed: true` | The idempotency key was reused. Use a new key for the changed records. | | A retryable `429` | Wait for the time in `Retry-After`, then retry. | ### Next steps - [Core concepts](https://docs.littlebigbrain.com/start/concepts/): the model, mapped from vector stores and graph databases. - [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/): filter hits by type and by link. - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): validate writes and follow a chain of any length. - [Connect an agent](https://docs.littlebigbrain.com/sdks/mcp/): give an agent the same tools through MCP. - [Build a workflow](https://docs.littlebigbrain.com/start/first-workflow/): test a workflow on two records, then let it fill values from their text. ## Build a workflow URL: https://docs.littlebigbrain.com/start/first-workflow/ Source: docs-site/src/content/docs/start/first-workflow.mdx Description: Test LBB's values.extract template on two records, save your answers as a dataset, then create the workflow and read what it writes. A workflow does work on your records after each published commit. The one on this page reads product data sheets. It fills four properties: the width, the depth, the material and the color. Each value comes with the words of the text that state it. You test the workflow before it writes. A test runs it on two records and writes nothing to the graph. You read each value with its quote, correct one, and keep your answers as a dataset. Then you create the workflow, and it writes the values. The workflow comes from `values.extract`, one of LBB's templates. LBB runs it, so you need no worker of your own. ### 1. Connect In the [console](https://cloud.littlebigbrain.com), open your stack's **Connect** view and copy its endpoint and an API key. To run on your machine instead, run `npm run dev:stack` in the LBB repository. Its endpoint is `http://127.0.0.1:7400`, and its key is `lbb_sk_test_local`. Set both in your terminal. The TypeScript and Python examples read them from there. The cURL steps use [jq](https://jqlang.org) to read fields of the answers. In cURL, `RUN` holds the time you start. It makes this run's idempotency keys unique. A request sent again with the same key does its work once. Each cURL step also sends `Lbb-Version: 2026-10-21`, the API version of this page. The SDKs send the version and a key for each write. ### cURL ```sh export LBB_URL="http://127.0.0.1:7400" export LBB_API_KEY="lbb_sk_test_local" export RUN="$(date +%s)" ``` ### TypeScript ```ts import { LbbClient, LbbError } from "@littlebigbrain/client"; const client = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY, graph: "main" }); ``` ### Python ```python import os import time from lbb import LbbClient, LbbError client = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="main") ``` Each example on this page adds to the one before it. In TypeScript and Python, keep them in one file. ### 2. Add the class and two records A workflow fills properties that the graph declares. Create the graph `main` first. If it exists, the create answers `409`, and you go on. Declare the class `Product`, its `text`, and the four properties to fill. A number property names its unit. The workflow then converts a number in another unit: `1.6 m` is 160 for `width_cm`. Then write two records from the template's samples. They are the data sheets of a conference table and of a drawer unit. The second one is in German. A test reads published records, so wait until the commit is published. ### cURL ```sh curl -sS -X POST "$LBB_URL/v1/graph/create?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' curl -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' \ -H 'Content-Type: application/json' \ --data '{"ops": [{"op": "add_entity_type", "name": "Product"}, {"op": "add_property", "name": "text", "value_type": "text"}, {"op": "add_property", "name": "width_cm", "value_type": "f64", "unit": "cm"}, {"op": "add_property", "name": "depth_cm", "value_type": "f64", "unit": "cm"}, {"op": "add_property", "name": "material", "value_type": "text"}, {"op": "add_property", "name": "color", "value_type": "keyword"}]}' curl -sS -X POST "$LBB_URL/v1/graph/commit?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' \ -H 'Content-Type: application/json' -H "Idempotency-Key: product-records-$RUN" \ --data '{"entity_properties": [{"type": "Product", "name": "T-160", "properties": {"text": "T-160 Conference table of oak veneer, width 1.6 m, depth 800 mm. Accessory: a cable tray of steel under the top."}}, {"type": "Product", "name": "R-3", "properties": {"text": "R-3 Rollcontainer aus Stahlblech, Breite 43, Höhe 60 cm, mit drei Schubladen. Farbe: lichtgrau."}}]}' for i in $(seq 120); do [ "$(curl -sS "$LBB_URL/v1/graph/publication-status?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' | jq -r '.state')" = current ] && break [ "$i" = 120 ] && echo "The commit is not published after 2 minutes." >&2 sleep 1 done R3=$(curl -sS "$LBB_URL/v1/graph/entity?graph=main&type=Product&name=R-3" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' | jq -r '.entity.id') ``` The loop asks every second if the commit is published, for at most two minutes. The last command keeps the id of record R-3 for step 4. ### TypeScript ```ts try { await client.createGraph(); } catch (error) { if (!(error instanceof LbbError && error.status === 409)) throw error; } await client.ontology.evolve({ ops: [ { op: "add_entity_type", name: "Product" }, { op: "add_property", name: "text", value_type: "text" }, { op: "add_property", name: "width_cm", value_type: "f64", unit: "cm" }, { op: "add_property", name: "depth_cm", value_type: "f64", unit: "cm" }, { op: "add_property", name: "material", value_type: "text" }, { op: "add_property", name: "color", value_type: "keyword" }, ], }); const written = await client.graph("main").facts.create({ entity_properties: [ { type: "Product", name: "T-160", properties: { text: "T-160 Conference table of oak veneer, width 1.6 m, depth 800 mm. Accessory: a cable tray of steel under the top.", }, }, { type: "Product", name: "R-3", properties: { text: "R-3 Rollcontainer aus Stahlblech, Breite 43, Höhe 60 cm, mit drei Schubladen. Farbe: lichtgrau.", }, }, ], }); await client.waitForPublished(written.commit_seq); ``` ### Python ```python try: client.create_graph() except LbbError as error: if error.status_code != 409: raise client.ontology.evolve({ "ops": [ {"op": "add_entity_type", "name": "Product"}, {"op": "add_property", "name": "text", "value_type": "text"}, {"op": "add_property", "name": "width_cm", "value_type": "f64", "unit": "cm"}, {"op": "add_property", "name": "depth_cm", "value_type": "f64", "unit": "cm"}, {"op": "add_property", "name": "material", "value_type": "text"}, {"op": "add_property", "name": "color", "value_type": "keyword"}, ] }) written = client.graph("main").facts.create( { "entity_properties": [ { "type": "Product", "name": "T-160", "properties": { "text": "T-160 Conference table of oak veneer, width 1.6 m, depth 800 mm. Accessory: a cable tray of steel under the top." }, }, { "type": "Product", "name": "R-3", "properties": { "text": "R-3 Rollcontainer aus Stahlblech, Breite 43, Höhe 60 cm, mit drei Schubladen. Farbe: lichtgrau." }, }, ] } ) client.wait_for_published(written["commit_seq"]) ``` ### 3. Test the template A model that reads values from text can read a wrong one. It can read the size of an accessory, or a height as a depth. The test shows what the workflow would write. Send the workflow as you will create it, in `install`. It names the template and the workflow's name, `product`. It names the class, the field to watch and the properties to fill. `sample: 2` runs it on the first two records of the class. The test goes from `queued` to `running`, then ends `done`. ### cURL ```sh TEST=$(curl -sS -X POST "$LBB_URL/v1/workflows/named/tests?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' \ -H 'Content-Type: application/json' -H "Idempotency-Key: product-test-$RUN" \ --data '{"install": {"starter": "values.extract", "name": "product", "watch": {"class": "Product", "fields": ["text"]}, "params": {"properties": ["width_cm", "depth_cm", "material", "color"]}}, "sample": 2}' | jq -r '.id') for i in $(seq 300); do [ "$(curl -sS "$LBB_URL/v1/workflows/named/tests/get?graph=main&id=$TEST" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' | jq -r '.status')" = done ] && break [ "$i" = 300 ] && echo "The test is not done after 5 minutes. Read its status and error." >&2 sleep 1 done curl -sS "$LBB_URL/v1/workflows/named/tests/get?graph=main&id=$TEST" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' ``` ### TypeScript ```ts const install = { starter: "values.extract", name: "product", watch: { class: "Product", fields: ["text"] }, params: { properties: ["width_cm", "depth_cm", "material", "color"] }, }; const started = await client.workflows.tests.create({ install, sample: 2 }); const test = await client.workflows.tests.wait(started.id); for (const result of test.results ?? []) { const label = test.inputs.find((input) => input.iri === result.iri)?.label; for (const value of result.values ?? []) { console.log(label, value.property, value.value, `"${value.quote}"`, value.outcome); } } ``` ### Python ```python install = { "starter": "values.extract", "name": "product", "watch": {"class": "Product", "fields": ["text"]}, "params": {"properties": ["width_cm", "depth_cm", "material", "color"]}, } started = client.workflows.tests.create(install=install, sample=2) test = client.workflows.tests.wait(started.id) labels = {each.iri: each.label for each in test.inputs} for result in test.results or []: for value in result.values or []: print(labels[result.iri], value.property, value.value, repr(value.quote), value.outcome) ``` For each record, the test keeps the text it read and the values. Each value has a `quote` and a `support`. The support says how well the quote supports the value, from 0 to 1. Its `outcome` says what the workflow does with it. `written` means the workflow writes the value. `review` means it asks a person first. On the local stack, T-160 gets three values. `width_cm` is 160 from `width 1.6 m`, and `depth_cm` is 80 from `depth 800 mm`. Its `material` is `oak veneer`, and the steel of the cable tray is left out. The text names no color, so `color` gets no value. R-3 also gets three values, and no depth: `Höhe 60 cm` is its height. Its `width_cm` 43 quotes `Breite 43`, which states no unit. So its outcome is `review`: a person confirms the unit before the value is written. ### 4. Correct a value and save the answers Your answers turn the test into examples. A dataset of examples lets you compare a later version of the workflow with this one. Say your product list is in English and names the material of R-3 `sheet steel`. Correct the value. The decisions `accept` and `reject` take the same route. An answer writes nothing to the graph. Then save the answers as the test's dataset. Each record you answered becomes an example. It holds the text, the values a correct run writes and the values it leaves out. ### cURL A record's IRI is `https://littlebigbrain.com/e/` followed by its id. In the bodies below, `'"$TEST"'` ends the single quotes. The shell then puts in the value of `TEST`. ```sh curl -sS -X POST "$LBB_URL/v1/workflows/named/tests/answer?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' \ -H 'Content-Type: application/json' -H "Idempotency-Key: product-answer-$RUN" \ --data '{"id": "'"$TEST"'", "iri": "https://littlebigbrain.com/e/'"$R3"'", "property": "material", "answer": {"decision": "correct", "value": "sheet steel"}}' curl -sS -X POST "$LBB_URL/v1/evals/datasets?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' \ -H 'Content-Type: application/json' -H "Idempotency-Key: product-dataset-$RUN" \ --data '{"name": "product answers", "kind": "values", "source": {"kind": "test", "id": "'"$TEST"'"}}' ``` ### TypeScript ```ts const r3 = test.inputs.find((input) => input.label === "R-3")!; await client.workflows.tests.answer({ id: test.id, iri: r3.iri, property: "material", answer: { decision: "correct", value: "sheet steel" }, }); const dataset = await client.evals.datasets.create({ name: "product answers", kind: "values", source: { kind: "test", id: test.id }, }); console.log(dataset.id, dataset.item_count); ``` ### Python ```python r3 = next(each for each in test.inputs if each.label == "R-3") client.workflows.tests.answer(test.id, r3.iri, "material", {"decision": "correct", "value": "sheet steel"}) dataset = client.evals.datasets.create("product answers", source={"kind": "test", "id": test.id}) print(dataset.id, dataset.item_count) ``` The dataset holds one example, R-3. Its `material` is `sheet steel`, and `Stahlblech` is a value to leave out. Answer more values and save again. A test has one dataset, and each save brings it up to date. ### 5. Create the workflow Create the workflow with the same body you sent as `install`. This is how you create a workflow from one of LBB's templates. The workflow first reads every record of the class. After that, it reads each record that a published commit adds or changes. Each step it takes is a run, with its outcome, its model calls and their cost. The list of runs has the list fields `data`, `has_more` and `next_cursor`, newest first. The workflow writes what the model reads. Your answers stay in the test and its dataset, so R-3 gets the material `Stahlblech`. The width of R-3 is not written: it waits for a person, as in the test. ### cURL ```sh curl -sS -X PUT "$LBB_URL/v1/workflows/triggered?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' \ -H 'Content-Type: application/json' \ --data '{"starter": "values.extract", "name": "product", "watch": {"class": "Product", "fields": ["text"]}, "params": {"properties": ["width_cm", "depth_cm", "material", "color"]}}' for i in $(seq 300); do [ "$(curl -sS "$LBB_URL/v1/workflows/named/runs?graph=main&name=product" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' | jq -r '.data[0].outcome')" = done ] && break [ "$i" = 300 ] && echo "The workflow has no done run after 5 minutes. Read its runs." >&2 sleep 1 done curl -sS "$LBB_URL/v1/workflows/named/runs?graph=main&name=product&limit=5" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' curl -sS "$LBB_URL/v1/workflows/named?graph=main&name=product" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' for i in $(seq 120); do [ "$(curl -sS "$LBB_URL/v1/graph/publication-status?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' | jq -r '.state')" = current ] && break [ "$i" = 120 ] && echo "The values are not published after 2 minutes." >&2 sleep 1 done curl -sS "$LBB_URL/v1/graph/entity?graph=main&type=Product&name=R-3" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Lbb-Version: 2026-10-21' ``` ### TypeScript ```ts await client.workflows.starters.use(install); let workflow = await client.workflows.named.get("product"); for (let second = 0; (workflow.status.counters?.instances ?? 0) < 2; second++) { if (second === 300) throw new Error("The workflow has not read both records after 5 minutes."); await new Promise((resolve) => setTimeout(resolve, 1_000)); workflow = await client.workflows.named.get("product"); } console.log(workflow.status.counters); const runs = await client.workflows.named.runs("product", { limit: 5 }); console.log(runs.data, runs.has_more, runs.next_cursor); const { head_seq } = await client.publicationStatus(); await client.waitForPublished(head_seq); const record = await client.entityDetail({ type: "Product", name: "R-3" }); console.log(record.attributes); ``` ### Python ```python client.use_workflow_starter( "values.extract", name="product", class_="Product", fields=["text"], params={"properties": ["width_cm", "depth_cm", "material", "color"]}, ) workflow = client.workflows.named.get("product") for second in range(301): if (workflow.status.counters or {}).get("instances", 0) >= 2: break if second == 300: raise TimeoutError("The workflow has not read both records after 5 minutes.") time.sleep(1) workflow = client.workflows.named.get("product") print(workflow.status.counters) runs = client.workflows.named.runs("product", limit=5) print(runs.data, runs.has_more, runs.next_cursor) client.wait_for_published(client.publication_status_model().head_seq) record = client.entities.detail(type="Product", name="R-3") print(record["attributes"]) ``` The workflow's `status.counters` count its work. `instances` counts the records it read: 2. `written` counts the values it wrote: 5. `review` counts the values it sent to a person: 1, the width of R-3. The record's `attributes` hold the values. Each value also has a link `HAS_VALUE_SOURCE` to its source. The link's evidence is the quote. Add a third record, and the workflow reads it after the commit is published. ### 6. Where to go next - **Review.** A value goes to review when its support is from 0.5 up to the gate. A number whose quote states no unit goes to review too. The gate is 0.8 unless you set `gate`. A person confirms or corrects the value, and the answer is written. `GET /v1/reviews?workflow=product` lists the items: here, the width of R-3. See [Ask a person](https://docs.littlebigbrain.com/guides/review-in-workflows/). - **Spend and Quality.** In the console, open **Operate → Workflows** and the workflow. **Spend** shows its model calls this month and its budget. **Quality** shows its gate, the model checks of its uses and its open review items. - **Your own code.** To change the prompt or the rules, run the template's code in your own worker. See [Workflow templates](https://docs.littlebigbrain.com/guides/workflow-templates/) and [Run a worker in Python](https://docs.littlebigbrain.com/guides/python-worker/). - **More on tests.** Every field is in [Test a workflow before it writes](https://docs.littlebigbrain.com/guides/test-a-workflow/), [Datasets of examples](https://docs.littlebigbrain.com/guides/datasets/) and [Starter workflows](https://docs.littlebigbrain.com/guides/starter-workflows/). ### If something goes wrong | What you see | What to do | | --- | --- | | `400 workflow_param_invalid`: the class has no published instances | The commit is not published yet. Wait for it, then start the test again. | | `400 workflow_param_invalid` that names a property | The graph lacks the property, or its type. Declare it as in step 2. | | `409 conflict` when you start a test | A test of this workflow is still open. Wait for it to end, or cancel it. | | A test that ends `failed` | Its `error` says why. The records it read keep their results. | | A wait that prints its message | The step did not end in time. Read the test's `status` and `error`, or the workflow's runs. | | `429 workflow_budget_exceeded` | The test may cost more than the monthly workflow budget has left. | | `503 workflows_disabled` | The server runs without workflows. On the local stack, use `npm run dev:stack`. | ## What is little big brain? URL: https://docs.littlebigbrain.com/start/overview/ Source: docs-site/src/content/docs/start/overview.md Description: little big brain is a search platform for AI applications. Vector search finds facts by meaning, a graph query follows them to the evidence, and every answer names the commit it read. little big brain is a search platform for AI applications and agents. It puts three things in one system, over one copy of your data: - **Search by meaning.** Vector search finds records that match a question in other words. We run the embedding models and keep the vectors current. - **A knowledge graph.** Records are linked. A graph query follows the links from a search hit to the facts behind an answer. - **History.** Each write is a commit. Each response names the commit it read, so you can replay an answer later. ### Why search and graph belong together Vector search has high recall. It finds text that means the same thing in other words. It cannot show how two facts connect, or whether an answer is complete. An agent then reads more chunks and guesses the connections. A graph query is precise. It returns only what the stored links support. It needs a starting point, and a question in natural language rarely names one. little big brain uses both, at one commit. Search finds the starting point. The graph proves the answer. The response says which version of the data it used. ### What you build with it - **Agents that cite their answers.** An answer comes with the records, links and evidence behind it. - **Support and operations assistants.** "Which release fixed the login bug?" The [quickstart](https://docs.littlebigbrain.com/start/quickstart/) builds this one. - **Tracing and exposure questions.** Which batches contain a recalled lot? Which services depend on a vulnerable library? See [use cases](https://docs.littlebigbrain.com/start/use-cases/). - **Audit trails.** Replay the query behind a decision on the data the agent read then. ### What the platform runs for you - **Embeddings.** The vectors live next to the records they describe. - **Re-embedding.** A record is embedded again when its fields change. - **Agreement between search and graph.** Search hits are checked against the graph at one commit. - **Storage and servers.** Your graph lives in object storage. Servers cache hot data on NVMe and hold no durable state. You pay per GiB stored. ### How you use it | Surface | Best for | | --- | --- | | [TypeScript SDK](https://docs.littlebigbrain.com/sdks/typescript/) (`@littlebigbrain/client`) | Node.js, API routes and edge functions | | [Python SDK](https://docs.littlebigbrain.com/sdks/python/) (`pip install littlebigbrain`) | Scripts, notebooks and data pipelines | | [MCP server](https://docs.littlebigbrain.com/sdks/mcp/) (`@littlebigbrain/mcp`) | Giving an agent search, query and write tools | | [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/) | Any language, and standard SPARQL tools | All clients call the same HTTP API with the same [stack API key](https://docs.littlebigbrain.com/start/authentication/). Load data with Python, query from TypeScript and let an agent read through MCP. All of them work on one graph. ### Built on open standards You write records as JSON. Underneath, they are stored as RDF, the W3C standard for linked data. Queries use SPARQL 1.1, validation rules use SHACL, and reasoning uses RDFS and OWL. You do not need to know these standards to start. They mean that standard tools such as YASGUI, Protégé and RDFLib connect directly. If your data is RDF already, you can [load it as it is](https://docs.littlebigbrain.com/guides/load-rdf/). The [core concepts](https://docs.littlebigbrain.com/start/concepts/#rdf-under-the-hood) explain the words you will meet. ### Where it runs little big brain is a hosted service in the EU. Each stack has its own endpoint at `--.db.eu.littlebigbrain.com`. You sign up in the [console](https://cloud.littlebigbrain.com), create a stack and copy its API key. The hosted MCP server is at `mcp.littlebigbrain.com`. A self-hosted distribution is planned. It will run against a local filesystem or an S3-compatible bucket. :::note[Current limits] SPARQL Update accepts `INSERT DATA` only. Each graph serves the default RDF graph only, so named-graph statements are refused. Background purge for large accounts is not implemented yet. ::: ### Where to go next - Run the [quickstart](https://docs.littlebigbrain.com/start/quickstart/): search, follow a path and cite a commit. - Learn the model in [core concepts](https://docs.littlebigbrain.com/start/concepts/). - See how the steps fit together in [how search works](https://docs.littlebigbrain.com/start/platform/). ## Core concepts URL: https://docs.littlebigbrain.com/start/concepts/ Source: docs-site/src/content/docs/start/concepts.md Description: The little big brain model for developers who know vector stores. Records, types, links, commits, search by meaning, graph queries, and the RDF words you will meet. If you have used a vector store, you know half of this model already. This page maps what you know to little big brain. Then it explains the two new parts: links between records, and commits. To run something first, start with the [quickstart](https://docs.littlebigbrain.com/start/quickstart/). This page uses its example: support tickets, bugs and releases. ### From a vector store | In a vector store | In little big brain | | --- | --- | | Index or collection | Graph | | Document or chunk | Record | | Metadata fields | Fields, with declared types | | No equivalent | Links between records | | Embeddings you compute and upsert | Embeddings the platform computes from the fields you choose | | Metadata filter | Filter by type and by link | | An upsert overwrites the old value | Each write is a new commit, and past commits stay readable | The main difference is the links. A vector store finds chunks that are similar to a question. It cannot tell you how two facts connect. Here, links are stored data. A query can follow them, and an answer can show them. ### Stacks and graphs A **stack** is an isolated workspace. It has its own endpoint, API keys and data. Use one stack per environment, such as `production` and `staging`. A **graph** is one dataset in a stack, such as `support`. Calls name it with `?graph=`, or with the `graph` option of an SDK client. The default is `main`. Internally, each stack is served as its own **tenant**. You see it only as the short id at the start of your endpoint's host. ### Records, types, links and fields | Term | In the API and RDF | What it is | Example | | --- | --- | --- | --- | | Record | Entity | One thing in your data | ticket 4821 | | Type | Class | A kind of record | `Ticket` | | Link | Relation | A named connection from one record to another | `REPORTED_AS`, from a ticket to a bug | | Field | Property | A typed value on a record | `description` | A record has a `type`, a `name` and usually a `key`. The key is the id from your source system. Two records with the same type and key are one record. A rename keeps the record. In a write, records look like this: ```json { "source": { "type": "Ticket", "key": "4821", "name": "login fails after 3.1" }, "relation": "REPORTED_AS", "target": { "type": "Bug", "key": "AUTH-112", "name": "session token expiry" }, "evidence": "Support triage, 2 Sep" } ``` A link can carry `evidence`, a note on why the fact was recorded. Put a source reference there, such as a document id or a URL. An agent can then cite it. Evidence can also name a `source_id` and a page `region`. Reads return it: `GET /v1/graph/entity` lists each link's evidence, and SPARQL reads it from the link's statement node. A document workflow names a file as the source: its `Document` IRI with the page, as in `…/e/41ab…#page=3`. A link can also carry `properties`: values of fields in your schema, such as a status. `GET /v1/graph/entity` returns them on each link. SPARQL reads them under `https://littlebigbrain.com/ep/`. ### Schema A graph accepts only the types, links and fields you declare. `define` declares types and links. `evolve` adds fields and more types later. A write can name an unknown type or field. It can give a value of the wrong type. Then the whole write fails, and nothing from it is stored. [Load your data](https://docs.littlebigbrain.com/start/load-data/) shows the order of steps. For rules across fields and links, publish **SHACL shapes**. An example rule is "a released batch names the lot it was produced from". In reject mode, the platform refuses a write that breaks a rule. The error reports what to fix. An agent can read the report, correct its write and retry. See [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). ### Commits Each write is a **commit** with a number, `commit_seq`. The numbers only go up. Each read reports the commit it answered from, as `served_at_seq`. Writes do not overwrite data in place. A new value is a new commit, and past commits stay readable. Pass `as_of_commit_seq` to run a query against the graph as it was at that commit. This works while the commit is retained. See [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/). A commit over HTTP needs an `Idempotency-Key` header. The SDKs create one when you do not pass it. A request with a key that was used before returns the first result with `replayed: true`. It writes nothing new, even when the payload changed. Use a new key for new data. ### When a read sees your write After a commit, the platform publishes it for queries and search. This usually takes a second or two. Reads take a `consistency` option: - `eventual` is the default. It reads the latest published commit. It is fast, and it can be a little behind the newest write. - `strong` also applies the newest writes. Use it to read your own write at once. To wait for one write, pass its `commit_seq` as `min_indexed_seq`. The read then covers that commit or a later one. Search follows published commits. Each embedding reports `lag_commits`, the number of commits it is behind the newest commit. It catches up after each publication. To search your own write, pass its `commit_seq` as `min_commit_seq`. ### Search by meaning You declare an **embedding** on a type and choose the fields to embed. The platform builds one text per record from those fields and embeds it. It keeps the vectors current as records change. One graph uses one embedding model. A search embeds your question and finds the nearest records. Then it checks each hit against the graph at one commit. A deleted record does not come back. You can filter by type and by link, such as "services that call payment-service". See [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). ### Graph queries with SPARQL Search finds starting points. A graph query follows the links from them. The query language is **SPARQL 1.1**, the W3C standard for graph queries. A SPARQL query is made of patterns. Each pattern is a record, a link, then a record or a value. Names that start with `?` are variables. The query returns the values that fit all patterns at once. This query lists each ticket with the release that fixed it: ```sparql PREFIX dclass: PREFIX dr: PREFIX rdfs: SELECT ?ticket ?release WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dr:reported_as ?b . OPTIONAL { ?b dr:fixed_in ?r . ?r rdfs:label ?release . } } ORDER BY ?ticket ``` `a` means "has type". A `;` starts the next pattern about the same record. `OPTIONAL` keeps a ticket whose bug has no fix yet. The names you declare in JSON have fixed global names in queries: | You declared | In SPARQL | Prefix line | | --- | --- | --- | | Type `Ticket` | `dclass:ticket` | `PREFIX dclass: ` | | Link `REPORTED_AS` | `dr:reported_as` | `PREFIX dr: ` | | Field `description` | `dp:description` | `PREFIX dp: ` | | A record's `name` | `rdfs:label` | `PREFIX rdfs: ` | The global names use lowercase. If you know Cypher, this table maps the common patterns: | Cypher | SPARQL | | --- | --- | | `MATCH (t:Ticket)` | `?t a dclass:ticket .` | | `(t)-[:REPORTED_AS]->(b)` | `?t dr:reported_as ?b .` | | `(a)-[:DEPENDS_ON*1..]->(b)` | `?a dr:depends_on+ ?b .` | | `t.description` | `?t dp:description ?d .` | | `WHERE NOT (e)-[:APPROVED_BY]->()` | `FILTER NOT EXISTS { ?e dr:approved_by ?x }` | | `RETURN t.name` | `SELECT ?name` with `?t rdfs:label ?name .` | [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/) covers filters, aggregates and paths. [Use cases](https://docs.littlebigbrain.com/start/use-cases/) shows six complete queries. ### RDF under the hood You do not need RDF to use little big brain. The words appear in responses and queries, so they help. Each fact is stored as a **triple**: a subject, a predicate and an object. "ticket 4821, `REPORTED_AS`, bug AUTH-112" is one triple. "ticket 4821, name, 'login fails after 3.1'" is another. A graph is a set of triples. This data model is **RDF**, a W3C standard. | Word | Meaning | | --- | --- | | IRI | A global name for a record, type or link. It looks like a URL, such as `https://littlebigbrain.com/class/ticket`. Records you write as JSON get IRIs such as `https://littlebigbrain.com/e/0ea0bc08…`. | | Literal | A plain value, such as a string, a number or a date. | | Class | A type. | | Ontology | The declared types, links and fields of a graph. | | SHACL | The W3C standard for validation rules. | | RDFS and OWL | W3C standards that say how types and links relate, such as "an active study site is a study site". | | Turtle, N-Triples | Text formats for RDF data. | RDF is the reason standard tools work here. You query with SPARQL, validate with SHACL, and connect tools such as YASGUI, Protégé or RDFLib. If your data is RDF already, [load it as it is](https://docs.littlebigbrain.com/guides/load-rdf/). ### Reasoning A query can also return facts that follow from your schema. Say the schema states that an active study site is a study site. A query for study sites can then include the active ones. You choose this per query with the `entailment` option. The default, `none`, returns the facts as written. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/). ### Where your data lives Your graph lives in object storage. Facts, indexes, vectors and history are immutable objects in one bucket. The servers in front cache hot data on local NVMe disks and keep no durable state. A server can restart or be replaced at any time. You pay for the bytes you store. See [Usage & billing](https://docs.littlebigbrain.com/start/billing/). ## Glossary URL: https://docs.littlebigbrain.com/start/glossary/ Source: docs-site/src/content/docs/start/glossary.md Description: Short definitions of the words for workflows, quality and review in little big brain, plus the engine words under Details. This page defines the words that the console and these docs use for workflows, quality and review. Each entry is one or two sentences. The guides explain each part in full. ### Workflows #### Workflow A durable process installed on a graph. It has a name, a trigger and its settings. See [Workflows](https://docs.littlebigbrain.com/guides/workflows/). #### Trigger What starts a workflow: on change, on upload, on schedule, on stream or on review answered. Today a workflow has one trigger: a watch (on change) or a content trigger (on upload). On schedule, on stream and on review answered are planned. #### Template A ready-made, versioned starting point for a workflow. The API calls it a starter workflow. **Use template** creates a workflow from it. See [Starter workflows](https://docs.littlebigbrain.com/guides/starter-workflows/). #### Run One item of work. A run groups its continuations and its retries. #### Attempt One try of a run. The console shows attempts only inside the details of a run. #### Step A checkpointed unit inside a run. When an attempt runs again, a finished step returns its saved result. #### AI steps The steps that use a model or a person: extract, classify, decide, embed and review. A workflow's code makes these calls through the [activities](https://docs.littlebigbrain.com/guides/workflow-templates/#the-activities). #### Agent step A model that uses tools in a bounded loop. Agent steps are planned. ### Quality #### Dataset Example inputs with their expected outputs. Datasets are coming to **Evals**. #### Test A run on chosen inputs before a workflow goes live. A test writes nothing. Tests are coming. #### Experiment Two versions compared on one dataset. The console calls model trials experiments. #### Check A judge model grades a sample of the model calls of a use. People can agree with a check or correct it. See [Check model quality](https://docs.littlebigbrain.com/guides/check-model-quality/). #### Gate The confidence below which a workflow asks a person instead of writing. A result at or above the gate is written. #### Calibration People label a sample of results. LBB uses the labels to measure a gate and set it. ### Review #### Review item A question for a person: approve, correct, choose, grade or answer. Its result is an answer. #### Review The way a workflow hands a decision to a person, and the inbox of review items. Today the inbox is the **Suggestions** tab of **Knowledge graph**. #### Use What a model call is for: ask, route, extract, decide, embed and more. **Models** picks the model for each use. ### Engine words The console shows these words only under a **Details** disclosure. You meet them in the workflow engine's routes and in its logs. | Word | Meaning | | --- | --- | | instance | An engine identity that receives messages. | | turn | One attempt of handling a message. | | shard | One of up to 16 identities that a workflow's work is routed to. | | lease | A worker's claim on a turn. | | inbox | The queued messages of an instance. | | origin | What caused a message or a review item. | ## Load your data URL: https://docs.littlebigbrain.com/start/load-data/ Source: docs-site/src/content/docs/start/load-data.mdx Description: Load a database and your documents into one graph as JSON, in four calls. Pick a use case, and every example on the page follows it. You load your data as JSON, from your own code. Rows from a database and sections from your documents become records and links in one graph. ### Four calls to start | Call | What it does | | --- | --- | | `define` | Declares your types and links | | `evolve` | Declares your fields | | `commit` | Writes your records and links as JSON | | `embeddings.declare` | Turns on search by meaning | Then you search in plain words and follow links with a query. You run no embedding model and no vector store. ### Pick your use case Every example on this page follows the use case you pick here. So does the guide [Add links with an LLM](https://docs.littlebigbrain.com/guides/add-links-with-an-llm/). ### Support **Your sources.** A ticket database and your release notes. **The question.** Which release fixed the login bug? **The graph.** ```text Ticket → REPORTED_AS → Bug ← COVERS ← Section → PART_OF → Document ``` ### Accounting **Your sources.** A ledger database and your accounting manual. **The question.** Why was JE-1042 booked to account 6815? **The graph.** ```text JournalEntry → BOOKED_TO → Account ← COVERS ← Section → PART_OF → Document ``` ### Legal **Your sources.** A contract database and the signed contract files. **The question.** Which contracts need consent after a merger? **The graph.** ```text Party ← HAS_PARTY ← Contract ← COVERS ← Section → PART_OF → Document ``` ### Life sciences **Your sources.** A batch record database and your deviation reports. **The question.** Which batches were made from the lot that failed testing? **The graph.** ```text Batch → MADE_FROM → Lot ← COVERS ← Section → PART_OF → Document ``` ### Security **Your sources.** A service catalog and your security advisories. **The question.** Which services are exposed to the log4j bug? **The graph.** ```text Service → DEPENDS_ON → Library ← COVERS ← Section → PART_OF → Document ``` Each use case loads two sources. The database gives the records and their links. The documents give the text that explains them. The `COVERS` link joins the two. ### What becomes what | Your data | In the graph | See | | --- | --- | --- | | A database or a spreadsheet | Rows become records. Foreign keys become links. | [Load a database](#2-load-a-database) | | Documents: a wiki, Notion pages, PDFs | Each section becomes a record with its text. | [Load documents](#3-load-documents) | | Facts that only the text states | An LLM reads the text and writes links. | [Add links with an LLM](https://docs.littlebigbrain.com/guides/add-links-with-an-llm/) | | RDF files: Turtle, N-Triples | Each statement is stored as written. | [Load RDF files](#load-rdf-files) | ### Set up the client The examples read the files of your use case from the current folder. They write to a graph named `kb`. To try a second use case, change the graph name. ### TypeScript ```ts import { readFileSync } from "node:fs"; import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "kb", }); const read = (file: string) => JSON.parse(readFileSync(file, "utf8")); ``` ### Python ```python import json import os import time from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="kb") def read(file): with open(file) as f: return json.load(f) ``` ### cURL ```sh export LBB_URL="https://--.db.eu.littlebigbrain.com" export LBB_API_KEY="" ``` The commands also use `jq`. The [quickstart](https://docs.littlebigbrain.com/start/quickstart/#1-get-your-credentials) shows where to find the endpoint and the API key. ### 1. Declare the schema A graph accepts only the types, links and fields you declare. So the schema comes first. `schema.json` names the types and the links between them. `fields.json` names the fields and their value types. ### Support ```json title="schema.json" { "entity_types": ["Ticket", "Bug", "Document", "Section"], "relation_types": [ { "name": "REPORTED_AS", "source": "Ticket", "target": "Bug" }, { "name": "PART_OF", "source": "Section", "target": "Document" }, { "name": "COVERS", "source": "Section", "target": "Bug" } ] } ``` ```json title="fields.json" { "ops": [ { "op": "add_property", "name": "priority", "value_type": "keyword" }, { "op": "add_property", "name": "opened_at", "value_type": "date_time" }, { "op": "add_property", "name": "text", "value_type": "text" } ] } ``` ### Accounting ```json title="schema.json" { "entity_types": ["JournalEntry", "Account", "Document", "Section"], "relation_types": [ { "name": "BOOKED_TO", "source": "JournalEntry", "target": "Account" }, { "name": "PART_OF", "source": "Section", "target": "Document" }, { "name": "COVERS", "source": "Section", "target": "Account" } ] } ``` ```json title="fields.json" { "ops": [ { "op": "add_property", "name": "amount", "value_type": "f64" }, { "op": "add_property", "name": "booked_on", "value_type": "date_time" }, { "op": "add_property", "name": "text", "value_type": "text" } ] } ``` ### Legal ```json title="schema.json" { "entity_types": ["Contract", "Party", "Document", "Section"], "relation_types": [ { "name": "HAS_PARTY", "source": "Contract", "target": "Party" }, { "name": "PART_OF", "source": "Section", "target": "Document" }, { "name": "COVERS", "source": "Section", "target": "Contract" } ] } ``` ```json title="fields.json" { "ops": [ { "op": "add_property", "name": "value", "value_type": "f64" }, { "op": "add_property", "name": "signed_on", "value_type": "date_time" }, { "op": "add_property", "name": "text", "value_type": "text" } ] } ``` ### Life sciences ```json title="schema.json" { "entity_types": ["Batch", "Lot", "Document", "Section"], "relation_types": [ { "name": "MADE_FROM", "source": "Batch", "target": "Lot" }, { "name": "PART_OF", "source": "Section", "target": "Document" }, { "name": "COVERS", "source": "Section", "target": "Lot" } ] } ``` ```json title="fields.json" { "ops": [ { "op": "add_property", "name": "quantity", "value_type": "i64" }, { "op": "add_property", "name": "made_on", "value_type": "date_time" }, { "op": "add_property", "name": "text", "value_type": "text" } ] } ``` ### Security ```json title="schema.json" { "entity_types": ["Service", "Library", "Document", "Section"], "relation_types": [ { "name": "DEPENDS_ON", "source": "Service", "target": "Library" }, { "name": "PART_OF", "source": "Section", "target": "Document" }, { "name": "COVERS", "source": "Section", "target": "Library" } ] } ``` ```json title="fields.json" { "ops": [ { "op": "add_property", "name": "team", "value_type": "keyword" }, { "op": "add_property", "name": "version", "value_type": "keyword" }, { "op": "add_property", "name": "text", "value_type": "text" } ] } ``` `define` creates the graph with your types and links. `evolve` then adds the fields. `merge_default: false` leaves out the built-in starter types. ### TypeScript ```ts await lbb.ontology.define({ format: "spec", merge_default: false, source: readFileSync("schema.json", "utf8"), }); await lbb.ontology.evolve(read("fields.json")); ``` ### Python ```python with open("schema.json") as f: lbb.ontology.define({"format": "spec", "merge_default": False, "source": f.read()}) lbb.ontology.evolve(read("fields.json")) ``` ### cURL ```sh jq -n --rawfile source schema.json '{format: "spec", merge_default: false, source: $source}' | curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/define?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" -d @- curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d @fields.json ``` Both calls are safe to repeat. If you write before you declare a schema, the graph gets a small starter schema. Your own types are then refused. ### 2. Load a database One rule maps each table to the graph: | In your database | In the graph | | --- | --- | | A table | A type | | A row | A record. The row's id is its `key`. A readable column is its `name`. | | A foreign key | A link | | A join table | One link per row | | A column | A field | A short script reads the rows and writes this JSON. Here are the tables and the JSON they become: ### Support Table `tickets`: | id | title | bug_id | priority | opened_at | | --- | --- | --- | --- | --- | | 4821 | login fails after 3.1 | AUTH-112 | high | 2026-09-02T09:15:00Z | | 4822 | invoice PDF is blank | PDF-31 | medium | 2026-09-03T14:40:00Z | Table `bugs`: | id | title | | --- | --- | | AUTH-112 | session token expiry | | PDF-31 | PDF fonts missing | ```json title="records.json" { "triplets": [ { "source": { "type": "Ticket", "key": "4821", "name": "login fails after 3.1" }, "relation": "REPORTED_AS", "target": { "type": "Bug", "key": "AUTH-112", "name": "session token expiry" } }, { "source": { "type": "Ticket", "key": "4822", "name": "invoice PDF is blank" }, "relation": "REPORTED_AS", "target": { "type": "Bug", "key": "PDF-31", "name": "PDF fonts missing" } } ], "entity_properties": [ { "type": "Ticket", "key": "4821", "name": "login fails after 3.1", "properties": { "priority": "high", "opened_at": "2026-09-02T09:15:00Z" } }, { "type": "Ticket", "key": "4822", "name": "invoice PDF is blank", "properties": { "priority": "medium", "opened_at": "2026-09-03T14:40:00Z" } } ] } ``` ### Accounting Table `journal_entries`: | id | memo | account_id | amount | booked_on | | --- | --- | --- | --- | --- | | JE-1042 | office chairs | 6815 | 1240.00 | 2026-09-12T00:00:00Z | | JE-1043 | design software | 4400 | 960.00 | 2026-09-15T00:00:00Z | Table `accounts`: | id | name | | --- | --- | | 6815 | Low-value office equipment | | 4400 | Software subscriptions | ```json title="records.json" { "triplets": [ { "source": { "type": "JournalEntry", "key": "JE-1042", "name": "JE-1042: office chairs" }, "relation": "BOOKED_TO", "target": { "type": "Account", "key": "6815", "name": "Low-value office equipment" } }, { "source": { "type": "JournalEntry", "key": "JE-1043", "name": "JE-1043: design software" }, "relation": "BOOKED_TO", "target": { "type": "Account", "key": "4400", "name": "Software subscriptions" } } ], "entity_properties": [ { "type": "JournalEntry", "key": "JE-1042", "name": "JE-1042: office chairs", "properties": { "amount": 1240.0, "booked_on": "2026-09-12T00:00:00Z" } }, { "type": "JournalEntry", "key": "JE-1043", "name": "JE-1043: design software", "properties": { "amount": 960.0, "booked_on": "2026-09-15T00:00:00Z" } } ] } ``` ### Legal Table `contracts`: | id | title | party_id | value | signed_on | | --- | --- | --- | --- | --- | | MSA-204 | master services agreement | P-17 | 480000.00 | 2025-03-01T00:00:00Z | | NDA-311 | mutual NDA | P-22 | 0.00 | 2026-01-15T00:00:00Z | Table `parties`: | id | name | | --- | --- | | P-17 | Meridian Cloud | | P-22 | Harbor Analytics | ```json title="records.json" { "triplets": [ { "source": { "type": "Contract", "key": "MSA-204", "name": "MSA-204: master services agreement" }, "relation": "HAS_PARTY", "target": { "type": "Party", "key": "P-17", "name": "Meridian Cloud" } }, { "source": { "type": "Contract", "key": "NDA-311", "name": "NDA-311: mutual NDA" }, "relation": "HAS_PARTY", "target": { "type": "Party", "key": "P-22", "name": "Harbor Analytics" } } ], "entity_properties": [ { "type": "Contract", "key": "MSA-204", "name": "MSA-204: master services agreement", "properties": { "value": 480000.0, "signed_on": "2025-03-01T00:00:00Z" } }, { "type": "Contract", "key": "NDA-311", "name": "NDA-311: mutual NDA", "properties": { "value": 0.0, "signed_on": "2026-01-15T00:00:00Z" } } ] } ``` ### Life sciences Table `batches`: | id | lot_id | quantity | made_on | | --- | --- | --- | --- | | CVX-A17 | 73091 | 120000 | 2026-08-04T00:00:00Z | | CVX-A18 | 73112 | 118500 | 2026-08-11T00:00:00Z | Table `lots`: | id | name | | --- | --- | | 73091 | Lot 73091 | | 73112 | Lot 73112 | ```json title="records.json" { "triplets": [ { "source": { "type": "Batch", "key": "CVX-A17", "name": "Batch CVX-A17" }, "relation": "MADE_FROM", "target": { "type": "Lot", "key": "73091", "name": "Lot 73091" } }, { "source": { "type": "Batch", "key": "CVX-A18", "name": "Batch CVX-A18" }, "relation": "MADE_FROM", "target": { "type": "Lot", "key": "73112", "name": "Lot 73112" } } ], "entity_properties": [ { "type": "Batch", "key": "CVX-A17", "name": "Batch CVX-A17", "properties": { "quantity": 120000, "made_on": "2026-08-04T00:00:00Z" } }, { "type": "Batch", "key": "CVX-A18", "name": "Batch CVX-A18", "properties": { "quantity": 118500, "made_on": "2026-08-11T00:00:00Z" } } ] } ``` ### Security Table `services`: | id | name | team | | --- | --- | --- | | svc-checkout | Checkout API | payments | | svc-search | Search API | discovery | Table `libraries`: | id | version | | --- | --- | | log4j-core | 2.14.1 | | jackson-databind | 2.15.2 | Table `service_libraries`: | service_id | library_id | | --- | --- | | svc-checkout | log4j-core | | svc-search | jackson-databind | ```json title="records.json" { "triplets": [ { "source": { "type": "Service", "key": "svc-checkout", "name": "Checkout API" }, "relation": "DEPENDS_ON", "target": { "type": "Library", "key": "log4j-core", "name": "log4j-core" } }, { "source": { "type": "Service", "key": "svc-search", "name": "Search API" }, "relation": "DEPENDS_ON", "target": { "type": "Library", "key": "jackson-databind", "name": "jackson-databind" } } ], "entity_properties": [ { "type": "Service", "key": "svc-checkout", "name": "Checkout API", "properties": { "team": "payments" } }, { "type": "Service", "key": "svc-search", "name": "Search API", "properties": { "team": "discovery" } }, { "type": "Library", "key": "log4j-core", "name": "log4j-core", "properties": { "version": "2.14.1" } }, { "type": "Library", "key": "jackson-databind", "name": "jackson-databind", "properties": { "version": "2.15.2" } } ] } ``` `triplets` holds the links. `entity_properties` holds the field values. Send the file as one commit: ### TypeScript ```ts const records = await lbb.graph("kb").facts.create(read("records.json"), { idempotencyKey: "kb-records-1", }); console.log("commit", records.commit_seq); ``` ### Python ```python records = lbb.graph("kb").facts.create(read("records.json"), idempotency_key="kb-records-1") print("commit", records["commit_seq"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: kb-records-1" -d @records.json ``` The idempotency key makes a retry safe. Use a new key for new data. For a table with many rows, stream the rows through an [import job](https://docs.littlebigbrain.com/sdks/http-api/#bulk-ndjson-ingest). ### 3. Load documents You split each document at its headings. Each section becomes a `Section` record. Its `text` field holds the words, and a `PART_OF` link names its document. Export PDFs and wiki pages to text first. Keep each section to a few paragraphs. Give each section a stable `key`, such as the document id plus the section number. A later load with the same key updates the section. ### Support ```md title="Release notes 3.2" ## Sign-in fixes Sessions no longer expire right after login. People who were sent back to the login screen after the 3.1 upgrade can sign in again. This fixes AUTH-112. ## Faster exports CSV exports now run on several threads. An export of 50,000 rows finishes in under ten seconds. ``` ```json title="sections.json" { "triplets": [ { "source": { "type": "Section", "key": "release-notes-3.2#1", "name": "Sign-in fixes" }, "relation": "PART_OF", "target": { "type": "Document", "key": "release-notes-3.2", "name": "Release notes 3.2" } }, { "source": { "type": "Section", "key": "release-notes-3.2#2", "name": "Faster exports" }, "relation": "PART_OF", "target": { "type": "Document", "key": "release-notes-3.2", "name": "Release notes 3.2" } } ], "entity_properties": [ { "type": "Section", "key": "release-notes-3.2#1", "name": "Sign-in fixes", "properties": { "text": "Sessions no longer expire right after login. People who were sent back to the login screen after the 3.1 upgrade can sign in again. This fixes AUTH-112." } }, { "type": "Section", "key": "release-notes-3.2#2", "name": "Faster exports", "properties": { "text": "CSV exports now run on several threads. An export of 50,000 rows finishes in under ten seconds." } } ] } ``` ### Accounting ```md title="Accounting manual" ## 3.2 Low-value equipment Book office equipment that costs less than 800 EUR per item to account 6815. Expense it in the month of purchase. Keep the receipt with the entry. ## 3.4 Software Book software subscriptions to account 4400. Spread an annual plan over twelve months. ``` ```json title="sections.json" { "triplets": [ { "source": { "type": "Section", "key": "accounting-manual#3.2", "name": "3.2 Low-value equipment" }, "relation": "PART_OF", "target": { "type": "Document", "key": "accounting-manual", "name": "Accounting manual" } }, { "source": { "type": "Section", "key": "accounting-manual#3.4", "name": "3.4 Software" }, "relation": "PART_OF", "target": { "type": "Document", "key": "accounting-manual", "name": "Accounting manual" } } ], "entity_properties": [ { "type": "Section", "key": "accounting-manual#3.2", "name": "3.2 Low-value equipment", "properties": { "text": "Book office equipment that costs less than 800 EUR per item to account 6815. Expense it in the month of purchase. Keep the receipt with the entry." } }, { "type": "Section", "key": "accounting-manual#3.4", "name": "3.4 Software", "properties": { "text": "Book software subscriptions to account 4400. Spread an annual plan over twelve months." } } ] } ``` ### Legal ```md title="MSA-204 signed copy" ## 14. Assignment Neither party may assign this Master Services Agreement MSA-204 without the other party's written consent. A merger or a change of control counts as an assignment. ## 15. Term This agreement runs for three years. It renews for one year at a time. ``` ```json title="sections.json" { "triplets": [ { "source": { "type": "Section", "key": "MSA-204.pdf#14", "name": "14. Assignment" }, "relation": "PART_OF", "target": { "type": "Document", "key": "MSA-204.pdf", "name": "MSA-204 signed copy" } }, { "source": { "type": "Section", "key": "MSA-204.pdf#15", "name": "15. Term" }, "relation": "PART_OF", "target": { "type": "Document", "key": "MSA-204.pdf", "name": "MSA-204 signed copy" } } ], "entity_properties": [ { "type": "Section", "key": "MSA-204.pdf#14", "name": "14. Assignment", "properties": { "text": "Neither party may assign this Master Services Agreement MSA-204 without the other party's written consent. A merger or a change of control counts as an assignment." } }, { "type": "Section", "key": "MSA-204.pdf#15", "name": "15. Term", "properties": { "text": "This agreement runs for three years. It renews for one year at a time." } } ] } ``` ### Life sciences ```md title="Deviation report DR-77" ## 2. Finding Lot 73091 failed the impurity test on 2 September. The level was above the release limit. Hold every batch made from this lot. ## 3. Action Quality assurance quarantined the remaining stock and informed the supplier. ``` ```json title="sections.json" { "triplets": [ { "source": { "type": "Section", "key": "DR-77#2", "name": "2. Finding" }, "relation": "PART_OF", "target": { "type": "Document", "key": "DR-77", "name": "Deviation report DR-77" } }, { "source": { "type": "Section", "key": "DR-77#3", "name": "3. Action" }, "relation": "PART_OF", "target": { "type": "Document", "key": "DR-77", "name": "Deviation report DR-77" } } ], "entity_properties": [ { "type": "Section", "key": "DR-77#2", "name": "2. Finding", "properties": { "text": "Lot 73091 failed the impurity test on 2 September. The level was above the release limit. Hold every batch made from this lot." } }, { "type": "Section", "key": "DR-77#3", "name": "3. Action", "properties": { "text": "Quality assurance quarantined the remaining stock and informed the supplier." } } ] } ``` ### Security ```md title="Security advisory SA-2021-44" ## Affected versions log4j-core versions 2.0 to 2.14.1 run lookups on logged text. An attacker can run code on the server with one crafted string. ## Fix Upgrade to log4j-core 2.17.1 or later. Until then, remove the JndiLookup class from the classpath. ``` ```json title="sections.json" { "triplets": [ { "source": { "type": "Section", "key": "SA-2021-44#1", "name": "Affected versions" }, "relation": "PART_OF", "target": { "type": "Document", "key": "SA-2021-44", "name": "Security advisory SA-2021-44" } }, { "source": { "type": "Section", "key": "SA-2021-44#2", "name": "Fix" }, "relation": "PART_OF", "target": { "type": "Document", "key": "SA-2021-44", "name": "Security advisory SA-2021-44" } } ], "entity_properties": [ { "type": "Section", "key": "SA-2021-44#1", "name": "Affected versions", "properties": { "text": "log4j-core versions 2.0 to 2.14.1 run lookups on logged text. An attacker can run code on the server with one crafted string." } }, { "type": "Section", "key": "SA-2021-44#2", "name": "Fix", "properties": { "text": "Upgrade to log4j-core 2.17.1 or later. Until then, remove the JndiLookup class from the classpath." } } ] } ``` Send the file with the same code as step 2 and a new idempotency key: ### TypeScript ```ts const sections = await lbb.graph("kb").facts.create(read("sections.json"), { idempotencyKey: "kb-sections-1", }); ``` ### Python ```python sections = lbb.graph("kb").facts.create(read("sections.json"), idempotency_key="kb-sections-1") ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: kb-sections-1" -d @sections.json ``` ### 4. Turn on search Declare an embedding on the `Section` type, from its name and its text. The platform embeds each section and keeps the vectors current. `label` is the record's `name`. An embedding reads published data. So the code first waits for the sections to publish. The first build then runs in the background, until the embedding reports `serving`. ### TypeScript ```ts await lbb.waitForPublished(sections.commit_seq); await lbb.embeddings.declare({ class: "https://littlebigbrain.com/class/section", from: ["label", "text"], }); while (!(await lbb.embeddings.get("section")).serving) { await new Promise((resolve) => setTimeout(resolve, 2_000)); } const found = await lbb.embeddings.search(read("search.json")); for (const hit of found.hits) console.log(hit.label); ``` ### Python ```python lbb.wait_for_published(sections["commit_seq"]) lbb.embeddings.declare("https://littlebigbrain.com/class/section", from_=["label", "text"]) while not lbb.embeddings.get("section").get("serving"): time.sleep(2) found = lbb.embeddings.search(**read("search.json")) for hit in found["hits"]: print(hit["label"]) ``` ### cURL ```sh until curl -sS "$LBB_URL/v1/graph/publication-status?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done curl --fail-with-body -sS -X PUT "$LBB_URL/v1/embeddings?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"class": "https://littlebigbrain.com/class/section", "from": ["label", "text"]}' until curl -sS "$LBB_URL/v1/embeddings?graph=kb&name=section" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"serving": *{'; do sleep 2; done curl --fail-with-body -sS -X POST "$LBB_URL/v1/search?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d @search.json | jq -r '.hits[].label' ``` `search.json` asks the question in your own words: ### Support ```json title="search.json" { "text": "users cannot sign in after the upgrade", "top_k": 3 } ``` The first hit is `Sign-in fixes`. ### Accounting ```json title="search.json" { "text": "where do I book a cheap office chair", "top_k": 3 } ``` The first hit is `3.2 Low-value equipment`. ### Legal ```json title="search.json" { "text": "who must consent before a merger", "top_k": 3 } ``` The first hit is `14. Assignment`. ### Life sciences ```json title="search.json" { "text": "which lot failed quality testing", "top_k": 3 } ``` The first hit is `2. Finding`. ### Security ```json title="search.json" { "text": "an attacker runs code through log messages", "top_k": 3 } ``` The first hit is `Affected versions`. A declaration spends model tokens. To preview the cost, see [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). It also shows how to filter hits by type or by link. ### 5. Connect the two sources The records and the sections are in one graph now. No link joins them yet. The `COVERS` link says which record a section is about. An LLM can read each section and write those links for you. The guide [Add links with an LLM](https://docs.littlebigbrain.com/guides/add-links-with-an-llm/) continues with the same graph and files. ### What the schema holds | Part | Example | Required | | --- | --- | --- | | Types | `Customer`, `Order` | At least one | | Links, with the types they connect | `PLACED`, from `Customer` to `Order` | At least one | | Fields, with a value type | `total` (number), `placed_at` (date and time) | No, but a field must be declared before a write uses it | | Parent types | `VipCustomer` is a kind of `Customer` | No | | Allowed values | `status` is `open` or `closed` | No | | A reverse name for a link | `PLACED_BY` for `PLACED` | No | Each field has a value type: | Value | `value_type` | | --- | --- | | Free text | `text` | | A code or label, such as a status | `keyword` | | A whole number | `i64` | | A decimal number | `f64` | | True or false | `bool` | | A date and time | `date_time` | The API calls the schema the **ontology**. The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/#evolve-the-schema) lists the other value types. ### Change the schema later Your data will grow. The schema grows with it through `evolve`: | To add | `op` | | --- | --- | | A type | `add_entity_type` | | A link | `add_relation` | | A field | `add_property` | | A parent type | `add_super_types` | | More types to an existing link | `widen_relation` | Additions are always safe. A change that removes or narrows something is blocked while stored data uses it. Add `?dry_run=true` to preview a change and the records it affects. The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/#evolve-the-schema) lists every operation. You can also call `define` again with a larger document. It adds what is new. It refuses a document that leaves out a type or link the graph has. That error is `400 ontology_identity_breaking_change`. ### Start from an ontology you have Skip this section if you have no schema file yet. Some teams already have their schema as an OWL file. OWL is a standard format for schemas. `define` reads such a file in Turtle, JSON-LD, RDF/XML or CSV. Set `format` to `turtle`, `json_ld`, `rdf_xml` or `csv`, and pass the file as `source`. ```turtle @prefix owl: . @prefix rdfs: . @prefix xsd: . @prefix ex: . ex:Customer a owl:Class . ex:Order a owl:Class . ex:VipCustomer a owl:Class ; rdfs:subClassOf ex:Customer . ex:placed a owl:ObjectProperty ; rdfs:domain ex:Customer ; rdfs:range ex:Order . ex:total a owl:DatatypeProperty ; rdfs:domain ex:Order ; rdfs:range xsd:decimal . ``` | In the OWL file | Becomes | | --- | --- | | `owl:Class` | A type: `Customer`, `Order`, `VipCustomer` | | `rdfs:subClassOf` | A parent type: a `VipCustomer` is a `Customer` | | `owl:ObjectProperty` | A link: `PLACED` | | `owl:DatatypeProperty` | A field: `total`, a decimal number | Link names become upper case. Writes match type and link names in any case. So `placed` and `PLACED` name the same link. ### Load RDF files RDF files need no schema. An RDF import stores each statement as written, and your queries use your own predicates. You can add rules and reasoning later. The first write to a graph decides its write model: - **RDF first.** The graph becomes RDF-native. It takes RDF import and SPARQL `INSERT DATA` from then on. A JSON write is refused with `400 rdf_native_graph`. - **JSON records first.** The graph keeps JSON writes, and it also takes RDF import. So to mix both formats in one graph, write the JSON records first. [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/) covers formats, large files and limits. ### Load a lot of data - **JSON records.** Stream newline-delimited JSON through an [import job](https://docs.littlebigbrain.com/sdks/http-api/#bulk-ndjson-ingest). The same schema rules apply, so declare the schema first. A job that meets an unknown type fails with `invalid_import`. - **RDF.** Send the file in chunks. See [Load a large dataset](https://docs.littlebigbrain.com/guides/load-rdf/#load-a-large-dataset). ### Common errors | Error | What to do | | --- | --- | | `unknown entity type 'Customer'` | Declare the type, with `define` or with the `evolve` op `add_entity_type`. | | `unknown relation 'RETURNED'` | Declare the link with the `evolve` op `add_relation`. | | `unknown property field 'priority'; declare it first` | Declare the field with the `evolve` op `add_property`. | | `property 'total' is declared F64 but its value is not a number` | Send a value of the declared type. | | `graph_not_found` from `evolve` | Call `define` first. It creates the graph. | | `ontology spec requires at least one named relation type` | Add at least one link to the `define` document. | | `400 ontology_identity_breaking_change` | Keep the existing types and links in the document, or use `evolve`. | | `400` with `no predicate named text` when you declare an embedding | The sections are not published yet. Wait for them, then declare again. | | `409 embedding_not_ready` from a search | The first build is still running. Wait until the embedding reports `serving`. | | `400 rdf_native_graph` | The graph was first written with RDF. Use RDF import, or load the JSON records into a new graph. | | An import job fails with `invalid_import` | Read the job's `error.message`, fix the schema or the data, then submit the job again with a new idempotency key. | ### Related - [Quickstart](https://docs.littlebigbrain.com/start/quickstart/): one worked example, from the first write to a cited answer. - [Add links with an LLM](https://docs.littlebigbrain.com/guides/add-links-with-an-llm/): join your records and your documents. - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): allowed values, parent types and rules. - [Core concepts](https://docs.littlebigbrain.com/start/concepts/): records, types, links and commits. ## How search works URL: https://docs.littlebigbrain.com/start/platform/ Source: docs-site/src/content/docs/start/platform.mdx Description: Find a starting fact by meaning, follow graph links to the evidence, and replay the answer at the commit it read. One support question, step by step. A question rarely uses the words stored in your data. Similarity finds a starting point. Links show whether that starting point leads to an answer. This page shows how the two work together. It uses the [quickstart](https://docs.littlebigbrain.com/start/quickstart/) example: **which release fixed the login bug?** ### Find the nearest facts The question says "users cannot sign in after the upgrade". The ticket says "login fails after 3.1". Vector search matches the two by meaning, so the ticket is the top hit. You choose which types are searchable and which fields to embed. The platform builds one text per record and embeds it. It keeps the vectors current as records change. Each hit is checked against the graph at one commit. A deleted record does not come back. Filters narrow a search by type and by link. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) covers the options. A search result is a candidate. A similarity score cannot show that the bug was fixed in a particular release. The next step checks the stored links. ### Follow the facts The ticket names the bug, and the bug names the release: ```text ticket 4821: login fails after 3.1 └─ REPORTED_AS → bug AUTH-112: session token expiry └─ FIXED_IN → Release 3.2 ``` A graph query follows both links. It starts at the ticket the search found: ```sparql PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE { dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . } ``` It returns `session token expiry` and `Release 3.2`. The agent can answer "Release 3.2 fixed it" and name the records and links behind it. Each link can also carry evidence, such as "Release notes 3.2", for the agent to cite. The graph can only confirm what you loaded. A query does not turn a missing fact into a verified one. When a link is absent, the query returns no row. The agent then knows it has no proof. ### Replay the answer A search response names the commit it read, as `served_at_seq`. A SPARQL response from `/v1/query/sparql-text` names it in `snapshot.served_at_seq`. Store it with the answer, together with the query and its rows. Say the answer read commit `412`. A later query with `as_of_commit_seq: 412` reads the same data, even after newer writes. This reproduces the evidence behind the answer. To reproduce the generated text too, keep the prompt, the model version and its settings. A commit stays readable while it is retained. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) explains what to save. It also covers commits that are no longer retained. ### Keep meaning with the data A schema says what your records mean. It declares the types, the links between them and the fields each type has. An agent can read the schema before it queries or writes. Rules make writes safe. Declare rules as SHACL shapes, such as "each bug has at most one fix release". In reject mode, the platform refuses a write that breaks a rule. The error reports what to repair. The agent fixes its write and retries. [Validate writes](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#published-shacl-conformance). The schema can also say how types relate. For example, an active study site is a study site. A query can then use that hierarchy. [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/) shows how. Schema suggestions come from the data you already have. They propose properties and rules that your records already follow. Review the evidence for a suggestion before you accept it. The schema stays an explicit part of the graph. ### Improve the next answer Feedback shows which results and paths helped an agent. Evaluation checks whether a change makes answers better or worse. Ranking can then pick a more useful starting point. The graph still supplies the links behind each answer. The [retrieval comparison](https://docs.littlebigbrain.com/start/database-comparison/) shows where vector, full-text, SQL and graph retrieval fit an application's questions. ### Truth in object storage Facts, indexes, vectors and history live in object storage. Serving nodes cache hot data on NVMe. You can add or replace a node without moving your data. This is the storage model behind the landing page's [architecture diagram](https://littlebigbrain.com/#storage). The [overview](https://docs.littlebigbrain.com/start/overview/) describes the whole system. [Usage and billing](https://docs.littlebigbrain.com/start/billing/) explains what counts as stored data. ## Use cases URL: https://docs.littlebigbrain.com/start/use-cases/ Source: docs-site/src/content/docs/start/use-cases.mdx Description: Six agent decisions, each with the records to store, the query that answers it, and what to keep so the decision can be replayed. The cases below are agent decisions with a real cost when the answer is wrong. They follow the same three steps: 1. **Find the starting record.** The user asks in their own words. Search by meaning finds the recall notice, the payment or the advisory. 2. **Prove the answer by path.** A SPARQL query follows the stored links from that record. The rows it returns are the evidence. 3. **Record the decision.** The agent stores its outcome with the commit it read. Anyone can replay the query later against the same data. Each case names the records to store, the query that answers it and what to record. All of them work with JSON records and the SDKs. [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/) walks through the first case end to end. [Load your data](https://docs.littlebigbrain.com/start/load-data/) shows how a database and your documents become such records. The queries use the prefixes for names you declare in JSON. Add these lines at the top of each query. [Core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql) explains them. ```sparql PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: ``` | The agent must decide | Pattern | Answered by | | --- | --- | --- | | Which batches and sites were exposed to a recalled lot | Chain of unknown length, identity across systems | Property paths, keyed entities | | Whether a payment needs escalation | Chain to a designated party, as of the payment date | Property paths, retained commits | | Which journal entries lacked independent approval | A required fact is absent | `FILTER NOT EXISTS` | | Which agreements need consent for an acquisition | One concept under several source labels, control chains | Class hierarchy, subclass entailment | | Which services were exposed to a vulnerability | Dependency paths through many unrelated records | Bounded property paths | | Which claims belong to one fraud ring | Records connected through shared things | Alternation and inverse paths | ### Recall exposure **Question.** A supplier recalls one ingredient lot. Which released batches contain its material, and which active study sites received those batches? **Records.** `IngredientLot`, `IntermediateLot`, `ReleasedBatch`, `StudySite` with the subtype `ActiveStudySite`, and `RecallNotice`. **Links.** `PRODUCED_FROM` between lots and batches, `RECALLS` from the notice and `RECEIVED_BATCH` from a site. Give lots a `key` from the manufacturing system. A lot recorded under a supplier id and an ERP id then stays one record. **Query.** `dr:produced_from+` follows the genealogy however deep it goes. The `activestudysite` type leaves a closed site out of the report. ```sparql SELECT ?batchNumber ?site WHERE { ?lot dp:lot_number "ZH-VAL-2018-07" . ?batch a dclass:releasedbatch ; dr:produced_from+ ?lot ; dp:batch_number ?batchNumber . OPTIONAL { ?siteNode a dclass:activestudysite ; dr:received_batch ?batch ; rdfs:label ?site . } } ``` **Afterwards.** Store an `AgentDecision` whose `based_on_commit_seq` is the `served_at_seq` of the response. The full walkthrough, with schema, writes, validation, and replay, is [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/). ### Payment escalation **Question.** Does transaction TXN-884 require escalation, and which beneficiary, control, and designation chain proves it? **Records.** `Transaction`, `Party` and `Designation`. Store the designation's `listed_at` as a `date_time` field. **Links.** `PAID_TO` from a transaction to its beneficiary. `CONTROLLED_BY` between parties. `DESIGNATED_UNDER` from a party to a sanctions designation. **Query.** Walk zero or more control links, then look for a designation. An `ASK` returns the yes or no; the `SELECT` form returns the chain. ```sparql SELECT ?beneficiary ?owner ?designation WHERE { ?txn dp:transaction_id "TXN-884" ; dr:paid_to ?beneficiary . ?beneficiary dr:controlled_by* ?owner . ?owner dr:designated_under ?designation . } ``` **Afterwards.** Screening runs at a moment in time. Store the response's `served_at_seq` with the decision. A reviewer may ask what the list held on the payment date. Replay the query with `as_of_commit_seq` to answer. A designation added the next day is absent from the replay. That is the correct answer to "what did the agent know". See [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/). ### Quarter-close audit **Question.** Which material journal entries in the quarter lacked independent approval? The policy in force at the time sets the rules. **Records.** `JournalEntry` with `amount` (`f64`) and `posted_at` (`date_time`), `Person`, and `Policy` with `effective_from` and `effective_to`. **Links.** `POSTED_BY` and `APPROVED_BY` from an entry to a person. `SCOPED_BY` from an entry to its policy. **Query.** A missing approval is an absent fact. In a graph, absence means "not recorded". A query asks for it with `FILTER NOT EXISTS`. The independence test compares the two people. ```sparql SELECT ?entry ?amount WHERE { ?entry a dclass:journalentry ; dp:amount ?amount ; dr:posted_by ?poster . FILTER(?amount >= 250000) FILTER NOT EXISTS { ?entry dr:approved_by ?approver . FILTER(?approver != ?poster) } } ``` The threshold comes from the policy in force on the posting date. Join the entry to its policy. Then compare `posted_at` with the policy window, with the same datatype on both sides. [FILTER comparisons and datatypes](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#filter-comparisons-and-datatypes) explains why a mismatched datatype silently drops the row. **Afterwards.** The audit finding cites the commit it was computed from. Read the published conformance report at the same time. Keep its `validated_at_seq` with the finding, so the record also names the rules in force. ### Acquisition consent **Question.** A company is being acquired. Which active agreements require counterparty consent? **Records.** `Agreement`, `Company`, and clause types. Contract data has two common problems. - One company appears under a contract id in one system and a registry id in another. Give the record one `key` and write both source records against it. - Each source names the clause differently. Declare `ChangeOfControlClause` and `AssignmentConsentClause` as types with `super_types: ["ConsentClause"]`. The graph stores both links as `rdfs:subClassOf` statements. See [Add a type hierarchy](https://docs.littlebigbrain.com/guides/reasoning/#add-a-type-hierarchy-to-json-records). **Query.** Ask for the parent type with `entailment: "subclass"`, and the subtypes match. `dr:controlled_by+` follows any number of ownership links. It finds a counterparty anywhere inside the acquired group. ```sparql SELECT ?agreement ?counterparty WHERE { ?agreement a dclass:agreement ; dp:status "active" ; dr:has_clause ?clause ; dr:counterparty ?counterparty . ?clause a dclass:consentclause . ?counterparty dr:controlled_by+ ?acquired . ?acquired dp:registry_id "HRB-123456" . } ``` OWL can go one step further. It can declare `requiresConsentFor` as a derived link. The agent's query is then a single pattern with the proof attached. That rule is a Turtle document. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/). **Afterwards.** Store the decision with its `served_at_seq`. The registry may later merge two companies. The earlier read stays reproducible. ### Vulnerability exposure **Question.** On the day an advisory is published, which customer-facing services embed the affected library version? **Records.** `Service` with a `customer_facing` boolean, `Artifact` with `version`, and `Advisory`. **Links.** `DEPENDS_ON` from a service to its artifacts, and between artifacts. `AFFECTS` from an advisory to an artifact version. A real corpus holds hundreds of unrelated service paths. The query must leave them out. **Query.** Start at the advisory. Then walk the dependencies backwards to the services that reach it. `^dr:depends_on+` reads "one or more `DEPENDS_ON` links, followed in reverse". ```sparql SELECT DISTINCT ?service WHERE { ?advisory dp:cve_id "CVE-2021-44228" ; dr:affects ?artifact . ?artifact ^dr:depends_on+ ?service . ?service a dclass:service ; dp:customer_facing true . } ``` Paths run under the same read limits as any query. A very large dependency graph returns a typed limit error. It never returns a partial list. Keep the start fixed, as above, so the walk starts from one record. **Afterwards.** Pin the query to the commit that held the dependency graph on disclosure day. A deployment the next week changes the latest data. The pinned read still returns the exposure list as it stood. ### Claims investigation **Question.** Which open claims belong to a coordinated repair-shop ring? **Records.** `Claim` with `status`, `Device`, `PayoutAccount` and `Repairer`. **Links.** `FILED_FROM` to a device, `PAID_TO` to an account, `REPAIRED_BY` to a repairer. **Query.** Two claims are connected when they share any of the three. The path below takes a link forward, then a link in reverse. It steps from a claim to a shared thing and back to another claim. ```sparql SELECT DISTINCT ?claim ?other WHERE { ?claim a dclass:claim ; dp:status "open" . ?claim (dr:filed_from|dr:paid_to|dr:repaired_by)/^(dr:filed_from|dr:paid_to|dr:repaired_by) ?other . ?other a dclass:claim ; dp:status "open" . FILTER(?claim != ?other) } ``` Repeat the alternation with `+` to follow the ring through several shared things. For large networks, page the result with `LIMIT` and `OFFSET`. Keep the start claim fixed. **Afterwards.** Store the investigation with the commit it read. A device may turn up in a new claim later. You can reopen the case, and the earlier network is still queryable at its own commit. ### What every case records Each decision above stores the same three things, all as ordinary records: - **The decision record.** Set its `based_on_commit_seq` to the `served_at_seq` of the query response. Store the outcome as a controlled value; `allowed_values` enforces the list. - **A link from the decision to its subject**, with an `evidence` string. The evidence stays on the fact. The change log shows when it was added. - **Nothing overwritten.** Later corrections are new commits. The replay with `as_of_commit_seq` returns the rows the agent read. The latest data shows the corrected state. - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): The recall case end to end: schema, writes, the chain query, validation, and replay. - [Replay answers & history](https://docs.littlebigbrain.com/guides/time-travel-audit/): Pin a read to a commit, replay an answer and find when it changed. - [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/): Subclass, RDFS, and OWL regimes, selected per query. - [MCP for agents](https://docs.littlebigbrain.com/sdks/mcp/): Give an agent the schema, query, and commit tools these cases use. ## Vector RAG, graph RAG, SQL and full-text search compared URL: https://docs.littlebigbrain.com/start/database-comparison/ Source: docs-site/src/content/docs/start/database-comparison.mdx Description: Compare vector, graph, SQL and full-text retrieval for AI agents on one connected-facts question, and see how little big brain combines search by meaning with graph paths. Retrieval-augmented generation (RAG) gives a model information to use when it answers. Vector RAG retrieves similar passages. Graph RAG retrieves connected facts and paths. Each is useful for different questions. little big brain runs both, over one copy of your data. This page compares the approaches on one agent question. **Which database does Auth Service write to, and who owns it?** The agent must find the service, check two relationships and explain its answer. ### What each approach gives an agent | Approach | What it does well | Work left to the agent or application | | --- | --- | --- | | Vector database | Find text with similar meaning, even in other words | Check which facts are relevant, how they connect and what the search missed. | | Full-text search | Find documents with names, codes or phrases | Check whether the matching text answers the question. | | Hybrid search | Combine word matching and search by meaning | Connect and verify facts from the returned documents. | | Relational database | Query records, join tables and calculate totals | Choose the right tables and joins, and understand what the fields mean. | | little big brain | Find records by meaning, then follow their stored links, check rules and read past commits | Declare the types and links, and load the facts and evidence. | ### What vector search leaves to the agent Vector search turns text into lists of numbers called **embeddings**. It compares those numbers to find text with similar meaning. This helps when a question and a document use different words for the same idea. Filters can narrow a search by stored fields, such as the source or the date. For an example, see [Pinecone's vector search and filtering API](https://sdk.pinecone.io/python/reference/sync-index.html). Take Auth Service. A plan to change its database looks similar to a report of its current setup. The search can return both. It can also leave out the document that names the service's owner. The agent must read the passages and search again for the missing facts. More text means more reading, and it does not guarantee that the needed facts are present. little big brain also runs vector search. An agent can find Auth Service from a vague question. The next step is different. The database, its owner and the links between them are records. The agent follows the links and gets the exact answer, with no passages to reconcile. ### What full-text search leaves to the agent Best Matching 25 (BM25) ranks documents by the words in a search. It considers how often words appear, how rare they are and how long the document is. BM25 works well for names, error codes and specific terms. It is also [Elasticsearch's default text similarity](https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity). Elasticsearch also supports filters, counts and [hybrid search](https://www.elastic.co/docs/solutions/search/hybrid-search), which combines word matching with vector search. The agent still has to connect the results. A document can mention Auth Service and User Database. It may not say that one writes to the other. The owner can appear in another document under another service name. Better search results help the agent find the text. The agent must still check the connections. In little big brain, the relationships are stored once and reused across questions. An agent follows the links from Auth Service to its database and owner. It can also read the evidence you recorded for each link. ### What a relational database leaves to the agent Relational databases use Structured Query Language (SQL) to query tables. Joins connect records across tables, and [recursive queries](https://www.postgresql.org/docs/17/queries-with.html) can follow a chain of records. SQL answers the service question exactly when the agent picks the right tables and joins. The hard part is the **schema**, the structure of those tables. Is the owner stored in `services`, `teams` or a linking table? Does a status field describe the current state or an old one? Could a join repeat rows and inflate a count? A query can run without errors and still answer the wrong question. little big brain gives the agent a schema it can read. The schema lists the types, the links and the fields of each type. The agent still has to use the right names. The links give it one direct way to connect a service, its database and its owner. ### How little big brain helps an agent check its answer The service question also needs an explanation. **What supports the answer, and which data version was used?** little big brain gives the agent a tool for each step: - **Find the starting record.** [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) finds Auth Service from the words the user chose. - **Read the data model.** [MCP tools](https://docs.littlebigbrain.com/sdks/mcp/) let an agent read the types, links and fields. - **Query the connections.** A query matches the service, its database and its owner by their links. - **Apply your rules.** [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/) applies the type hierarchy you declare. For example, an authentication service can count as a service. - **Read the evidence.** A link can carry its source, so the agent can cite it. - **Repeat the query on the same data.** Each response names its commit. Query that commit again [while it is retained](https://docs.littlebigbrain.com/guides/time-travel-audit/). The database checks links and applies rules. The agent chooses the queries and explains the facts they return. Answer quality depends on the facts, evidence and rules you provide. ### If you use a relational database Records in little big brain are grouped by type, and links connect them. You can write records as JSON. The query language is **SPARQL**, the standard for graph queries. [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/) shows the whole flow. | You know | In little big brain | What changes | | --- | --- | --- | | Table | A type | A record can have more than one type. | | Row | A record | A record has no fixed row shape. Fields can be missing. | | Primary key | The record's type plus its `key` | Each record also gets a global name, an IRI. Use the same key across sources for the same thing. | | Foreign key and join | A link, and a shared query variable | Links are stored as data. A variable used twice joins the matching facts. | | Recursive query | A property path, such as `dr:depends_on+` | `+` follows one or more links of the same kind. | | `WHERE`, `GROUP BY`, aggregates | SPARQL `FILTER`, `GROUP BY` and aggregates | The same ideas, over patterns instead of tables. | | Constraints | Declared fields and SHACL shapes | Unknown fields are refused. Shapes add rules across fields and links. | | Database | A graph | A graph holds the records and links you query together. | #### A join, expressed as a link Suppose a service writes to a database. In SQL, a `writes_to` table connects two rows. In little big brain, one link connects the two records. The examples ask the same question: **which database does Auth Service write to?** ### SQL ```sql -- services(id, name), databases(id, name), writes_to(service_id, database_id) SELECT d.name AS database FROM services s JOIN writes_to w ON w.service_id = s.id JOIN databases d ON d.id = w.database_id WHERE s.name = 'Auth Service'; ``` ### JSON records ```json { "triplets": [ { "source": { "type": "Service", "key": "auth-service", "name": "Auth Service" }, "relation": "WRITES_TO", "target": { "type": "Database", "key": "user-db", "name": "User Database" } } ] } ``` ### SPARQL ```sparql PREFIX dr: PREFIX rdfs: SELECT ?database WHERE { ?service rdfs:label "Auth Service" ; dr:writes_to ?db . ?db rdfs:label ?database . } ``` The result is `User Database`. Variables begin with `?`, and a variable used twice must match the same value. A `;` starts the next pattern about the same record. The prefix lines shorten long names; see [core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql). ### Choose your next step - [Quickstart](https://docs.littlebigbrain.com/start/quickstart/): search by meaning, then follow a path, in ten minutes. - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): declare types, validate writes and replay a decision. - [Use cases](https://docs.littlebigbrain.com/start/use-cases/): six agent decisions and the query behind each one. - [Core concepts](https://docs.littlebigbrain.com/start/concepts/): the model, mapped from vector stores and graph databases. ## Choosing an SDK URL: https://docs.littlebigbrain.com/sdks/overview/ Source: docs-site/src/content/docs/sdks/overview.mdx Description: All the ways to talk to little big brain (TypeScript, Python, MCP, and raw HTTP) and when to use each. Every client below speaks the **same HTTP API** and authenticates with the same [stack API key](https://docs.littlebigbrain.com/start/authentication/). You can load data with one client and query with another, on the same graph. The first-party SDKs (`@littlebigbrain/client`, `lbb`) are generated from one API contract. Their shapes match across languages. ### Comparison | SDK / surface | Package | Runtime | Use it when… | | --- | --- | --- | --- | | [TypeScript client](https://docs.littlebigbrain.com/sdks/typescript/) | `@littlebigbrain/client` | Node 18+, browsers, edge workers | You're building a JS/TS app, API route, or edge function | | [Python client](https://docs.littlebigbrain.com/sdks/python/) | `littlebigbrain` (imports as `lbb`) | Python 3.10+ (`httpx` + `pydantic`) | Scripts, notebooks, data pipelines | | [MCP server](https://docs.littlebigbrain.com/sdks/mcp/) | `@littlebigbrain/mcp` | Node (stdio) or hosted HTTP | You want an AI agent (Claude, Cursor, Codex) to search, query and write the graph | | [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/) | n/a | Any language | No SDK for your language, or you want the native SPARQL Protocol | ### The same flow everywhere Whatever you pick, the flow is the same: 1. Declare your types and links. See [Load your data](https://docs.littlebigbrain.com/start/load-data/). 2. Write records as JSON. 3. Turn on search for a type. 4. Search by meaning, then follow links with SPARQL. The [quickstart](https://docs.littlebigbrain.com/start/quickstart/) shows the flow in TypeScript, Python and cURL. If your data is RDF already, [load it as it is](https://docs.littlebigbrain.com/guides/load-rdf/). Standard SPARQL tools need no SDK. Each stack serves the SPARQL 1.1 Protocol at `/sparql`. YASGUI, Protégé and RDFLib's `SPARQLWrapper` connect with the endpoint URL and a bearer token. ### Local development and legacy tools Run `lbb-server` locally and connect the same HTTP clients to its local URL. For exact local queries, `lbb-testctl query sparql` also reads RDF with SPARQL. Class-declared embeddings use `/v1/embeddings*` and `/v1/search`, as shown in [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). The older `lbb-testctl graph search`, `multi-search`, `full-text` and record-index `embeddings` commands, and Python's `LbbLocalClient` search methods, are deprecated for new integrations. Their existing execution and arguments remain unchanged; no removal version is set. Their BM25, fusion and explicit record-vector contracts are different from class embedding search and have no automatic conversion. ### Pick one - [TypeScript](https://docs.littlebigbrain.com/sdks/typescript/): Dependency-free fetch wrapper; typed request/response shapes. - [Python — lbb](https://docs.littlebigbrain.com/sdks/python/): Sync + async, dict or typed Pydantic models. - [MCP server — @littlebigbrain/mcp](https://docs.littlebigbrain.com/sdks/mcp/): Task-shaped tools for agents; local stdio or hosted OAuth. - [HTTP API & SPARQL](https://docs.littlebigbrain.com/sdks/http-api/): REST plus the native SPARQL 1.1 Protocol endpoint. ## TypeScript URL: https://docs.littlebigbrain.com/sdks/typescript/ Source: docs-site/src/content/docs/sdks/typescript.mdx Description: The TypeScript client for little big brain. Declare types and links, write records, search by meaning, follow links with SPARQL and replay each answer at its commit. `@littlebigbrain/client` is the TypeScript client for little big brain. Use it in Node.js services, API routes and edge functions. Each method calls the [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/) and returns typed JSON. This page uses the example of the [quickstart](https://docs.littlebigbrain.com/start/quickstart/): support tickets, bugs and releases. The sections from **Create a client** to **Pin a commit** form one program. Add each block to the end of one file and run it with `npx tsx`. The reference sections after them cover the rest of the client. The package has no dependencies. It calls the global `fetch` of Node 18+, browsers and edge workers. Source and issues: [github.com/littlebigbrains/lbb-typescript](https://github.com/littlebigbrains/lbb-typescript) (Apache-2.0). ### Install ```sh npm install @littlebigbrain/client ``` This page describes version 0.31.0. ### Create a client ```ts import { LbbClient, LbbError, parseSparqlResults } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); ``` `baseUrl` is the complete endpoint from the environment's **Connect** view in the console. `apiKey` is an environment API key. `graph` names the graph that the client reads and writes. Without it, the server uses `main`. One client serves one graph. `lbb.graph("name")` returns the same namespaces for another graph, plus `facts` for writes. `lbb.withScope({ graph: "name" })` returns a new client with the same options. > [!CAUTION] Keep API keys on your server, in API routes or in edge functions with secret bindings. Never put a key in a browser bundle. See [Authentication](https://docs.littlebigbrain.com/start/authentication/). | Option | Default | Meaning | | --- | --- | --- | | `baseUrl` | required | The environment endpoint. | | `apiKey` | none | An environment API key (`lbb_sk_test_…` or `lbb_sk_live_…`) or a single-mode token. It is sent as a bearer token. | | `graph` | the server's `main` | The graph, sent as `?graph=`. | | `environment` | none | Environment slug for an account-scoped session token. Uses the compatible `stack` wire parameter. An environment key already fixes its environment. | | `stack` | none | Deprecated alias for `environment`; conflicting values fail before a request. | | `integrationsUrl` | `https://api.littlebigbrain.com` | The integrations API that `integrations` calls with the same key. See [Integrations for your customers](https://docs.littlebigbrain.com/guides/integrations-for-your-customers/). | | `defaultConsistency` | the server's `eventual` | The read consistency when a call names none. See [Read your own writes](#read-your-own-writes). | | `timeoutMs` | `120000` | The timeout of one attempt, including the response body. `0` disables it. | | `retryBudgetMs` | `60000` | The total time that retries of one call may take. | | `maxRetries` | `6` | The most retries of one call. | | `retryDelayMs` | `100` | The base delay of the backoff. | | `fetch` | the global `fetch` | Your own `fetch` implementation. | | `onRequest`, `onResponse`, `onRetry` | none | Callbacks for request events. They never receive bodies or credentials. | | `apiVersion` | `2026-10-21` | The value of the `lbb-version` header. An earlier date keeps the list shapes of that date; see [Lists](https://docs.littlebigbrain.com/sdks/http-api/#lists). | ### Declare types and links A graph accepts the record types, links and fields that you declare. A write that does not fit is refused. `ontology.define` declares types and links and creates the graph. `ontology.evolve` adds fields. ```ts await lbb.ontology.define({ format: "spec", merge_default: false, source: JSON.stringify({ entity_types: ["Ticket", "Bug", "Release"], relation_types: [ { name: "REPORTED_AS", source: "Ticket", target: "Bug" }, { name: "FIXED_IN", source: "Bug", target: "Release" }, ], }), }); await lbb.ontology.evolve({ ops: [ { op: "add_property", name: "description", value_type: "text" }, { op: "add_property", name: "priority", value_type: "keyword" }, ], }); ``` Both calls are safe to repeat. `define` returns `changed: false` for a document the graph already has. It applies an additive change, such as a new type or a wider link. `define` refuses a change that narrows or removes what the graph declares. The error codes are `ontology_restrictive_change`, `ontology_identity_breaking_change` and `ontology_unsupported_change`. Nothing is written then. `merge_default: false` leaves out the built-in default types. `source` also accepts JSON-LD, Turtle, RDF/XML and CSV. `evolve(body, { dryRun: true })` previews a change. [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/) covers parent types, inverse links and allowed values. ### Write records A write is a **commit**. `facts.create` takes links between records (`triplets`) and field values (`entity_properties`). A record is its `type` plus a `key`, and it has a `name`. A link can carry `evidence`, the reason it was recorded. ```ts const login = { type: "Ticket", key: "4821", name: "login fails after 3.1" }; const invoice = { type: "Ticket", key: "4822", name: "invoice PDF is blank" }; const csvExport = { type: "Ticket", key: "4823", name: "CSV export times out" }; const auth = { type: "Bug", key: "AUTH-112", name: "session token expiry" }; const pdf = { type: "Bug", key: "BILL-7", name: "PDF fonts missing" }; const slowExport = { type: "Bug", key: "DATA-58", name: "export runs on one thread" }; const r32 = { type: "Release", key: "3.2", name: "Release 3.2" }; const r311 = { type: "Release", key: "3.1.1", name: "Release 3.1.1" }; const written = await lbb.graph("support").facts.create( { triplets: [ { source: login, relation: "REPORTED_AS", target: auth, evidence: "Support triage, 2 Sep" }, { source: invoice, relation: "REPORTED_AS", target: pdf, evidence: "Support triage, 3 Sep" }, { source: csvExport, relation: "REPORTED_AS", target: slowExport, evidence: "Support triage, 4 Sep" }, { source: auth, relation: "FIXED_IN", target: r32, evidence: "Release notes 3.2" }, { source: pdf, relation: "FIXED_IN", target: r311, evidence: "Release notes 3.1.1" }, ], entity_properties: [ { ...login, properties: { priority: "high", description: "Since 3.1, people are sent back to the login screen right after they type their password." } }, { ...invoice, properties: { priority: "low", description: "Downloaded invoices open as empty pages." } }, { ...csvExport, properties: { priority: "low", description: "Exports with more than 10,000 rows stop after 30 seconds." } }, ], }, { idempotencyKey: "sdk-support-1" }, ); await lbb.waitForPublished(written.commit_seq); console.log("commit", written.commit_seq); ``` The response reports `commit_seq`, the number of the commit. `waitForPublished` waits until the latest published data covers that commit. Search and default reads use published data. This usually takes a second or two. Each write carries an idempotency key. The client creates one per call, and its own retries of that call reuse it. Pass your own `idempotencyKey` so that a rerun of your code writes nothing new. A second call with the same key returns the first result with `replayed: true`. This holds even when the payload changed, so use a new key for new data. Field writes merge: a commit that sends one field keeps the other fields. An unknown type, link or field fails the whole commit. So does a value of the wrong type. A commit body is at most 1 MiB. For more, use [NDJSON import](#import-ndjson). ### Turn on search Declare an **embedding** on a type and name the fields to embed. The platform builds one text per record, embeds it and keeps the vectors current. The first build runs in the background. ```ts const TICKET = "https://littlebigbrain.com/class/ticket"; await lbb.embeddings.declare({ class: TICKET, from: ["label", "description"] }); while (!(await lbb.embeddings.get("ticket")).serving) { await new Promise((resolve) => setTimeout(resolve, 2_000)); } ``` `class` is the global name of the type: its name in lowercase after `https://littlebigbrain.com/class/`. `label` is the record's `name`. The embedding's name defaults to the last part of the class, here `ticket`. The namespace also has `list`, `preview`, `setModel`, `refresh` and `delete`. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) covers each one, with the errors and the costs. ### Search by meaning ```ts const found = await lbb.embeddings.search({ text: "users cannot sign in after the upgrade", top_k: 3, }); for (const hit of found.hits) console.log(hit.score.toFixed(3), hit.label); ``` Each hit has an `iri`, a `label`, a `score` and a `class`. The `iri` is the global name of the record. `served_at_seq` is the commit that the search checked each hit against. `filter` keeps hits by type and by link. `include: ["text"]` returns the embedded text of each hit. `explain: true` plans the search and calls no model. ### Starter workflows A starter workflow is a template for work that follows a type. A workflow created from it reads, after each published commit, the records that the commit added or changed, and does its work. An embedding is a workflow of the starter workflow `search.embed`; fit from text is `ontology.fit`. ```ts await lbb.workflows.starters.use({ starter: "search.embed", watch: { class: TICKET, fields: ["label", "description"] }, }); const ticket = await lbb.workflows.named.get("ticket"); console.log(ticket.status.state, ticket.status.lag_commits); for await (const workflow of lbb.workflows.named.all()) { console.log(workflow.name, workflow.starter); } ``` `workflows.starters.list()` lists the starter workflows the server offers, and `workflows.starters.use(request)` creates a workflow from one or changes it. `workflows.triggered.preview` shows what creating one would do without writing anything. `workflows.named` reads and changes the workflows of the graph by name: `list({ limit, cursor })` returns one page in the [list envelope](https://docs.littlebigbrain.com/sdks/http-api/#lists) and `all()` iterates every workflow, `get` reads one, `run` asks it to run now, and `delete` repeats the name as `confirm`. `use`, `pause` and a `preview` without `propose` retry a failed call. [Starter workflows](https://docs.littlebigbrain.com/guides/starter-workflows/) covers the fields, the status and the errors. A developer workflow (a workflow of `workflow`) sends the records to a workflow of your worker. `use` takes its `workflow`, `owns` and `batch`, and `workflows.named.pause(name, paused)` pauses or resumes it. `triggeredWorkflow()` defines the workflow that gets the trigger messages, and `writeOwned()` commits what your code owns with `replace_owned` inside a step, under the step's effect id, so a turn that runs again does not write twice. `workflows.named.put` creates or changes a workflow of your worker, with `shards`, `route_by` and `window`, and a `watch` or a `content` trigger. `queue(name, { cursor, limit })` lists what the trigger has not sent yet, `runs(name, { cursor, limit, outcome })` and `allRuns(name)` its runs, `runResults(name, { run, outcome, iri, cursor, limit })` and `allRunResults(name)` what its runs did with each input, and `documents(name, { q, sort, cursor, limit })` and `allDocuments(name)` the documents of a document workflow. A `WorkflowWorker` records those results for you: `writeOwned`, `review` of an item with a `subject`, and `requeue` report each input with the turn's completion ([Run results](https://docs.littlebigbrain.com/guides/workflows/#run-results)). `workflows.derivations` checks and records what one version of a workflow made of one file (`check`, `record`); `derive(key, work)` does the check, the work and the record. [Developer workflows](https://docs.littlebigbrain.com/guides/starter-workflows/#developer-workflows) covers the message and the rules, with a worked example; the repository's `database/examples/starter-workflows/word-count.mjs` is a runnable one. `commit` and `graph(name).facts.create` send `replace_owned` and return `owned`; `ontology.suggestions.create` sends `facts`, and an accepted suggestion has `facts_commit`. A message names instances by IRI. `entityRef(iri, type, name?)` turns one into the commit reference of that record (its `key` is the id sentinel and the 32-hex id), so `writeOwned` reaches a keyed record too: ```ts import { entityRef } from "@littlebigbrain/client"; const subjects = message.instances.map((iri) => entityRef(iri, "Note")); ``` In `replace_owned` only the id counts. A link endpoint or a field row writes its name as the record's display name, so pass the record's name as the third argument there. `entityIdFromIri(iri)` returns the id alone. ### Files The document workflows read the files of a graph. `files` uploads them and reads what the parse made of them: ```ts import { readFile } from "node:fs/promises"; const { file } = await lbb.files.upload(await readFile("manual.pdf"), { name: "manual.pdf", contentType: "application/pdf", }); const { files } = await lbb.files.list(); const pages = await lbb.files.pages(file.sha256, { from: 0, to: 2 }); for (const line of pages.pages[0]?.lines ?? []) console.log(line.id, line.box, line.text); ``` `upload` takes a `Blob`, an `ArrayBuffer` or a `Uint8Array` of at most 1 GiB and sends it as the raw body; the same bytes under the same name again change nothing, so a failed call is retried. `language` (the natural language of the content, a BCP 47 tag such as `en` or `de-CH`, at most 64 characters) and `schema` (a name or a URL, at most 256) describe the content; the file's `Document` stores each one given. `list()` returns the newest 1,000 files; `list({ limit, after })` reads one page (`limit` 1 to 1,000), and the answer's `next` is the `after` of the next page, absent at the end. `get(sha256)` reads one file's record, and `counts()` counts the files by parse status, as of the commit the published generation holds. `content(sha256)` returns the bytes as a `Uint8Array`, and `delete(sha256)` repeats the SHA-256 as `confirm`. `content` holds the whole answer. Read a large file in chunks as they arrive with `contentStream`, ask for one byte range with `range`, and read a file's size, type and SHA-256 without its bytes with `head`: ```ts import { createWriteStream } from "node:fs"; const out = createWriteStream("manual.pdf"); for await (const chunk of lbb.files.contentStream(file.sha256)) out.write(chunk); out.end(); const lastKiB = await lbb.files.content(file.sha256, { range: { suffix: 1024 } }); const firstKiB = await lbb.files.content(file.sha256, { range: { start: 0, end: 1023 } }); const { bytes, contentType, sha256 } = await lbb.files.head(file.sha256); ``` A range with no byte of the file throws `LbbError` 416 `range_not_satisfiable`. A page range is 0-based with `to` excluded. Also on `graph(name).files`. `putPages(sha256, parser, { uploaded_at, status: "parsed", pages })` stores the pages a parser of your own made (`PUT /v1/files/pages`). `uploaded_at` is the record's (`get(sha256)`): a file deleted and uploaded again since answers `409 file_upload_changed`. The same pages again store nothing, so a retry is safe. [From documents to a graph](https://docs.littlebigbrain.com/guides/documents-to-graph/) creates the document workflows and reads the links they write. ### Follow links with SPARQL Search finds a starting point. A graph query follows the stored links from it to the answer. The query language is SPARQL 1.1, the W3C standard for graph queries. [Core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql) explains the syntax. ```ts const ticket = found.hits[0].iri; const readAt = found.served_at_seq!; const query = ` PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE { <${ticket}> dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . }`; const { rows } = await lbb.sparqlRows({ query, as_of_commit_seq: readAt }); console.log(rows, "read at commit", readAt); ``` With a real embedding model, the login ticket comes first. On a new graph, the program then prints: ```text [ { bug: 'session token expiry', release: 'Release 3.2' } ] read at commit 1 ``` `as_of_commit_seq` runs the query at the commit the search read. Search and query then agree on one version of the data. ### Pin a commit Store `readAt` with the answer. Later writes do not change that commit. The same query with the same `as_of_commit_seq` returns the same rows while the commit is retained. ```ts const r321 = { type: "Release", key: "3.2.1", name: "Release 3.2.1" }; const hotfix = await lbb.graph("support").facts.create( { triplets: [{ source: auth, relation: "FIXED_IN", target: r321, evidence: "Release notes 3.2.1" }] }, { idempotencyKey: "sdk-support-2" }, ); await lbb.waitForPublished(hotfix.commit_seq); const latest = await lbb.sparqlRows({ query }); const replay = await lbb.sparqlRows({ query, as_of_commit_seq: readAt }); console.log(latest.rows.length, replay.rows.length); ``` With the login ticket as the first hit, this prints `2 1`. The latest data names two releases. The replay reads commit 1, which names one. A pin past the newest commit returns 400 `invalid_request`. A commit that is no longer retained returns 400 `historical_generation_unavailable`. The server never answers from a nearby commit instead. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) covers retention. ### SPARQL The client runs SPARQL 1.1 SELECT and ASK queries. The examples in this and the next sections use the `support` graph from the program above. | Method | Returns | | --- | --- | | `sparqlRows(body, opts?)` | Parsed results: `{ vars, boolean, bindings, rows, snapshot }`, with `rowPage`, `nextCursor`, `traceId`, `search` and `profile` when the server sends them. Retries a retryable `429`. | | `sparqlText(body, opts?)` | The unparsed response. `results` holds SPARQL Results JSON as a string, next to `row_page`, `snapshot` and `next_cursor`. Retries a retryable `429`. | | `parseSparqlResults(response)` | The parsed results of a `sparqlText` response. | | `query.sparql(body, opts?)` | Parsed results, like `sparqlRows`. Retries a retryable `429`. | | `query.sparqlRaw(body, opts?)` | The unparsed response, like `sparqlText`. Retries a retryable `429`. | `rows` flattens each binding to `{ variable: value }`. `bindings` keeps the typed terms, with `type`, `value`, `datatype` and `xml:lang`. `boolean` is the answer of an ASK query, and `null` for SELECT. `snapshot.served_at_seq` is the commit that an eventual or pinned read used. A plain strong read returns `snapshot: null`. ```ts const ask = await lbb.sparqlRows({ query: "ASK { ?t ?b }", }); const raw = await lbb.sparqlText({ query: "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }" }); console.log(ask.boolean, parseSparqlResults(raw).rows[0].n, raw.row_page.total); ``` The body takes these fields: | Field | Meaning | | --- | --- | | `query` | The SPARQL text, SELECT or ASK. | | `as_of_commit_seq` | Run the query at this retained commit. | | `entailment` | `none` (default), `subclass`, `rdfs` or `owl`. The others add facts that follow from the schema. See [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/). | | `limit`, `offset` | Return a window of the result rows. The response's `row_page` reports `returned`, `total` and `has_more`. | | `cursor` | Pages that stay on one commit. `""` starts, and `next_cursor` from the response continues. The query needs one indexed, ordered `LIMIT` of 1 to 10,000, no `OFFSET`, and no `limit` or `offset` field. Other queries return 400 `invalid_pagination_cursor`; page them with `limit` and `offset`. | | `request` | The user's words behind the query. The server then records an eval trace, and the parsed results name it in `traceId`. | | `reason` | `true` returns a typed error. Stored rules already run when data is published. | CONSTRUCT and DESCRIBE return RDF documents, so these methods refuse them with 400. Run them on the native `/sparql` endpoint. See [Native SPARQL protocol](#native-sparql-protocol). #### Search inside a query A query can find records by meaning. The pattern `?x search:similarTo "text"` binds the records nearest to the text. The rest of the query then filters, joins and orders them like other rows. `search:score` binds the score. The prefix `search:` is `https://littlebigbrain.com/search#`, and the graph needs an [embedding](#turn-on-search). ```ts const near = await lbb.sparqlRows({ query: ` PREFIX search: PREFIX rdfs: SELECT ?ticket ?score WHERE { ?t search:similarTo "sign-in fails" ; search:score ?score ; rdfs:label ?ticket . } ORDER BY DESC(?score) LIMIT 3`, }); console.log(near.rows, near.search?.plan, near.search?.complete); ``` `search` reports how the search ran: the `plan` (`nearest`, `filter_first` or `search_first`), the hits asked for (`top`) and bound (`hits`), `complete`, and `lag_commits`, how far the vectors trail the commit the query read. [Search inside a SPARQL query](https://docs.littlebigbrain.com/guides/search-by-meaning/#search-inside-a-sparql-query) lists the other patterns and explains the plans. A search query with `cursor` returns 400. Page it with `search:top` and `LIMIT`. > [!TIP] Global names are lowercase The types, links and fields you declare have global names such as ``, `` and ``. The last part is always lowercase. An uppercase name is a different IRI and matches nothing. IRIs that you load with `importRdf` keep their original form. ### Questions in plain words `query.ask` answers a question in plain words. The server describes the graph. A decision model picks the kind of question. A reasoning model runs queries in a short loop, reads their rows, and answers. ```ts const result = await lbb.query.ask("Which release fixed the login bug?"); if (!result.answer) console.log("the loop stopped:", result.error); console.log(result.answer, result.citations, result.rows); ``` | Method | Returns | | --- | --- | | `query.ask(question, opts?)` | Sends the question to `POST /v1/query/ask`. Returns `answer` (the text), `citations`, `chart` (how to draw the rows, or `null`), `steps`, `route`, `query`, `rationale`, the parsed `rows` and `vars` of the query that holds the answer, `error`, `traceId`, `linked` (the names the server found), `anchors` and `history`. `response` holds the whole response. `opts` takes `context`, `route`, `limit`, `asOfCommitSeq`, `today`, `includeGrounding`, `anchor` (the IRIs of records the user picked) and `timeline` (the commit of each date). `opts.mode: "route"` returns the route alone. | | `query.askStream(question, opts?)` | Takes the arguments of `query.ask` and yields one event per step: `grounding`, `route`, `step` per tool call, `answer`, and last `done` with the response. An `error` event throws `LbbError`. Abort `opts.signal` to stop the server's work. | | `query.names({ text, limit? })` | The records a question or a list of names names: `candidates` with `text`, `iri`, `label`, `class`, `score` and `by`, the one to prefer first. No model call. | | `query.describe({ question?, classes?, properties? })` | The classes and properties a question needs, with how many sampled instances hold each property and the values of small classes, as JSON and as `text`. No model call. | | `query.commitAt({ date } \| { moment })` | The commit of a date or a moment: `as_of_commit_seq`, `committed_at` and `resolved_by`, or a `note`. | | `query.compare({ query, before, after?, key?, max_rows?, limit?, cursor? })` | Runs one `SELECT` at two points and returns `added`, `removed` and `changed` with `totals`, `truncated` and `next_cursor`. | `traceId` names the eval trace of the query that holds the answer. The question is its request. [Answer questions in plain words](https://docs.littlebigbrain.com/guides/answer-questions/) covers the routes, the options and the limits. It also lists the data that goes to the model providers. ### Model checks and search tuning A judge model checks a sample of the model calls LBB makes for a graph: the search rerank, the query router and rewriter, the ontology fit and the eval labels. `checks` reads the checks. A person can agree with a check or correct it, and the review becomes the call's ground truth. [Check model quality](https://docs.littlebigbrain.com/guides/check-model-quality/) explains the jobs and the scores. ```ts const month = await lbb.checks.summary(); console.log(month.judge?.agreement, month.jobs); const { checks } = await lbb.checks.list({ verdict: "wrong", reviewed: false }); for (const check of checks) console.log(check.call, check.summary, check.judge.reason); await lbb.checks.review(checks[0].call, { agree: false, verdict: "right", note: "The first hit answers the question.", }); ``` | Method | Returns | | --- | --- | | `checks.summary({ month? })` | One month per job and model: the checks, the score, right, partly and wrong, and the reviews. Also the judge's agreement with people and today's budget. | | `checks.list(opts?)` | The month's checks, newest first. `job`, `month`, `verdict`, `reviewed`, `after` and `limit` filter and page. | | `checks.review(callId, body)` | `{ agree: true }` keeps the judge's verdict. `{ agree: false, verdict, score?, reference?, note? }` corrects it. Returns the check. | | `checks.export({ job?, month? })` | The month's checks as parsed JSON lines, each `{ call, check }`. | | `checks.calls(opts?)` | The call log, newest first. `job`, `day`, `checked`, `after` and `limit` filter and page. | | `checks.call(callId)` | One call with its input, its output and its check. | | `checks.checkCall(callId)` | Asks the judge to check one call now. Returns `{ queued: true }`. | A search tuning session tests other search settings on the graph's own searches. The judge grades the hits, and the session proposes the settings that rank best. The settings change only when you apply the proposal. ```ts let session = await lbb.embeddings.searchTuning.start(); while (session.status === "queued" || session.status === "running") { await new Promise((resolve) => setTimeout(resolve, 10_000)); session = await lbb.embeddings.searchTuning.get(session.id); } if (session.proposal) await lbb.embeddings.searchTuning.apply(session.id); ``` `searchTuning.list({ limit? })` lists the sessions, newest first. `embeddings.setSearchSettings` changes the settings by hand. It takes `rerank`, `rerank_depth`, `blend` and `probe_factor`. A field you leave out keeps its value, and `null` sets it back to its default: ```ts await lbb.embeddings.setSearchSettings({ rerank_depth: 60, blend: null }); ``` `checks.checkCall` and `searchTuning.start` spend the platform's judge budget. The client sends them once unless you pass `{ retry: true }`. ### Model choice: test and switch models Each use of a model on a graph (question answers `ask`, query routing `route`, search rerank `rerank`, ontology fit `fit`, eval labels `label`) runs one model. A trial tests another model on the use's checked calls and scores both models against the same ground truth. When the candidate meets the bar, switch the use to it, and revert in one step. [Check model quality](https://docs.littlebigbrain.com/guides/check-model-quality/) explains the scores and the bar. ```ts // What each use runs now, and the models you can test. const { jobs } = await lbb.models.trials.options(); // Test Claude Haiku 5.5 at low effort on question answers. const { trials: [trial] } = await lbb.models.trials.create({ candidate: { provider: "anthropic", model: "claude-haiku-5-5", effort: "low" }, jobs: ["ask"], }); // The trial runs on the server. Wait until it compared the checked calls. const result = await lbb.models.trials.wait(trial.id, { onUpdate: (t) => console.log(`${t.report.calls} calls compared`), }); const { report } = result; console.log(report.current.score, report.candidate.score, report.ci_low, report.ci_high); if (report.qualifies) { await lbb.models.switches.create({ trial: result.id }); } // Back to LBB's model. await lbb.models.switches.revert("ask"); ``` | Method | Returns | | --- | --- | | `models.trials.options()` | Per use: the model it runs now, LBB's model, a switch, its checked calls, and the models you can test with their efforts and prices. | | `models.trials.create({ candidate, jobs?, target?, days? })` | `{ trials, skipped }`: one trial per use. An open trial of the same use and candidate comes back as it is. | | `models.trials.wait(id, opts?)` | The trial once it compared what it can (`until: "compared"`, the default) or ended (`until: "ended"`). `onUpdate`, `pollIntervalMs`, `timeoutMs` and `signal` control the wait. | | `models.trials.list({ job?, limit? })` | The trials, newest first, with their reports. | | `models.trials.get(id)` | One trial with its report and its compared calls. | | `models.trials.call(id, callId)` | One compared call: both answers and the ground truth. | | `models.trials.stop(id)` | Stops a trial. It keeps what it compared. | | `models.switches.create({ trial })` | Switches the trial's use to its candidate. | | `models.switches.list()` | The switched uses. | | `models.switches.revert(job)` | Puts the use back on LBB's model. | | `models.choices.list({ workflow?, use?, limit?, cursor? })` | The model of each workflow step (`extract`, `decide`, `embed`): the environment's rows, and with `workflow` the row in force for each step of that workflow, with the models each can run (`options`). `all` pages through it. | | `models.choices.put({ use, model, workflow? })` | Chooses the model of a step for the environment or one workflow. The next calls run it. The client sends an idempotency key. | | `models.choices.delete({ use, workflow? })` | Removes a choice: the step runs the environment's model, else LBB's. Returns `{ id, object, deleted: true }`. | A report gives both models' right share, mean score (0 to 1), cost per call and median time; the difference with its 95% interval (`delta`, `ci_low`, `ci_high`); and `qualifies`: at least 20 calls, at most 5 points worse at the low end, no more failures, and cheaper or better. A switch refuses a trial that does not qualify (`409 trial_not_qualified`) or that compared with another model than the one the use runs now (`409 trial_outdated`): start a new trial. Catch them as `LbbError` and read `error.code`. ### Read your own writes Reads take a `consistency` option: - `eventual` is the default. It reads the latest published commit, and the response's `snapshot.served_at_seq` names it. - `strong` also applies the newest writes. Use it to read your own write at once. Its response has no `snapshot` block. `minIndexedSeq` sets a floor: the read must cover that commit or a later one. A floor without a `consistency` reads strong. ```ts const note = await lbb.graph("support").facts.create({ entity_properties: [{ ...invoice, properties: { priority: "high" } }], }); const now = await lbb.sparqlRows( { query: "SELECT ?p WHERE { ?t ?p }" }, { consistency: "strong", minIndexedSeq: note.commit_seq }, ); console.log(now.rows.length); ``` Publication may not cover the floor yet. Then an eventual read returns 429 `read_your_writes_pending` with a `Retry-After`. `sparqlRows`, `sparqlText`, `query.sparql` and `query.sparqlRaw` retry it within the retry budget. You can also call `waitForPublished` first. Use `strong` when you need the answer now. Use `eventual` with a floor when you must record which commit you read. `consistency` and `minIndexedSeq` work on `sparqlRows`, `sparqlText`, `sparql`, `summary`, `query.sparql`, `query.sparqlRaw` and `query.structured`. `ontology.conformance` and `entities.detail` take `consistency` only. ### Load RDF If your data is RDF already, load it as it is. `facts.importRdf` sends one document in N-Triples, Turtle, N-Quads or TriG. [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/) walks through a full import. ```ts const turtle = ` @prefix ex: . @prefix rdfs: . ex:auth-service rdfs:label "auth-service" ; ex:writesTo ex:user-db . ex:user-db rdfs:label "user-db" . `; const imported = await lbb.graph("catalog").facts.importRdf(turtle, { format: "turtle", baseIri: "https://example.org/", idempotencyKey: "catalog-2026-09-25", }); console.log(imported.triples_read, imported.committed_commit_seq); ``` The first import creates the graph with a fixed generic schema, so you declare nothing first. Source IRIs keep their original form in queries. The response reports `triples_read`, `committed_commit_seq`, `error_count` and the first `errors`. | Option | Meaning | | --- | --- | | `format` | `ntriples` (default), `turtle`, `nquads` or `trig`. | | `baseIri` | The base for relative IRIs. | | `blankNodeScope` | Scopes blank-node labels to one source document. An ASCII token of letters, digits and `-_.:/`. | | `batch` | Statements per internal commit. Default 100,000, maximum 1,000,000. | | `strict` | `true` fails the request at the first bad statement. By default the importer skips it and reports it in `errors`. | | `observedAt` | An RFC 3339 time to record on every statement. | | `build` | `false` holds back publication for this request. | | `idempotencyKey` | The key for this request. The client creates one if you omit it. | | `graphUri` | Refused with 400. Each graph serves the default RDF graph only. | Keep each request under 64 MiB. N-Triples and N-Quads have one statement per line. Split a large file on line boundaries. Turtle and TriG requests must each be a complete document. Omit `blankNodeScope` to keep blank-node labels the same across chunks and retries. Reuse one scope for every chunk of one source document. Documents that reuse the same labels then stay apart. `facts.importRdfMany([{ rdf }, …], opts)` imports several documents in order. Only the last request triggers publication. Pass its `finalSequence` to `waitForPublished`. With an `idempotencyKey`, document `n` uses the key suffixed with `:n`. `query.update(text, opts?)` runs SPARQL Update on the `/update` endpoint. The server accepts `INSERT DATA`, and one request is one commit. Other update forms return 400. The client sends an idempotency key, so a retry replays the write. The answer has no body, so read the new facts with a `strong` read. ```ts await lbb.graph("catalog").query.update( 'INSERT DATA { "gold" }', { idempotencyKey: "catalog-tier-1" }, ); ``` A graph whose first write is RDF only takes RDF writes. On it, `facts.create`, `commitDryRun`, `facts.import`, `retract` and `reload` return 400 `rdf_native_graph`. A durable import job fails. Write JSON records first when you need both. ### Import NDJSON For many records, send newline-delimited JSON. Each line is a triplet, or a record with `type`, `name`, an optional `key` and `properties`. `facts.import(lines)` imports in one request of at most 64 MiB. The server splits the lines into bounded commits and returns `committed_commit_seq`, the last one. ```ts const tickets = [ { type: "Ticket", key: "4824", name: "search returns stale results", properties: { priority: "high" } }, { source: { type: "Ticket", key: "4824", name: "search returns stale results" }, relation: "REPORTED_AS", target: { type: "Bug", key: "SRCH-9", name: "cache not invalidated" } }, ]; const sync = await lbb.graph("support").facts.import(tickets, { idempotencyKey: "tickets-4824" }); console.log(sync.committed_commit_seq); ``` For a large dataset, use `submitImport`. It streams an array, an iterable or an async iterable into a durable import job. It never holds the whole dataset in memory. The job runs on the server and survives a lost connection. ```ts async function* records() { yield { type: "Ticket", key: "4825", name: "PDF export fails", properties: { priority: "low" } }; } const accepted = await lbb.submitImport(records(), { idempotencyKey: "tickets-2026-09-25" }); const status = await lbb.waitForImportJob(accepted.job_id); if (status.state !== "succeeded") throw new Error(status.failure?.message); console.log(status.committed_commit_seq); ``` - `idempotencyKey` is required. It binds the key to the uploaded content. - The client uploads once and does not retry the stream. To retry, call again with a fresh iterable and the same key. - An empty source fails in the client before any request. - `getImportJob(id)` reads the status, and `cancelImportJob(id)` cancels the job. - `waitForImportJob` polls every second and takes `pollIntervalMs`, `timeoutMs` and `signal`. The durable route needs the server capability `durable_import_jobs_v1`. The client checks `GET /version` first and throws `LbbCapabilityError` if it is missing. A request is at most 32 GiB. Both methods take `batch`, the records per internal commit, up to 10,000. The default is 1,000 for `facts.import` and 10,000 for a job. `strict: true` fails at the first malformed line, which is skipped by default. `observedAt` records an RFC 3339 time on every record. The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/) documents the job states and failure fields. ### Structured queries `sparql(body)` is a SELECT or ASK query written as JSON. It takes link patterns, typed filters, `group_by`, aggregates, `having` and `order_by`. Use it when you build a query from user input and want no query text. ```ts const pairs = await lbb.sparql({ patterns: [{ subject: { var: "ticket" }, predicate: "REPORTED_AS", object: { var: "bug" } }], select: ["ticket", "bug"], limit: 50, }); console.log(pairs.solutions.length); ``` `entities.filterByAttributes` builds that body for you. The `patterns` bind variables through links. `where` compares declared fields on those variables. `op` is `eq` (default), `ne`, `lt`, `le`, `gt` or `ge`. ```ts const urgent = await lbb.entities.filterByAttributes({ patterns: [{ subject: { var: "ticket" }, predicate: "REPORTED_AS", object: { var: "bug" } }], where: [{ var: "ticket", field: "priority", value: "high" }], select: ["ticket", "bug"], }); console.log(urgent.solutions.length); ``` A value can also be `{ dateTime: "…" }` or `{ entity: selector }`. `group_keys` groups by a field value or by a calendar bucket of a date field. See [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/) for the full request. ### Schema and SHACL `ontology.view()` returns the declared types, links and fields of the graph. With `{ counts: true }`, each link also reports how many current links it has. For rules across fields and links, publish SHACL shapes, the W3C standard for validation rules. `schema.publish` stores the shapes and a mode. `schema.view()` returns the active shapes and mode. ```ts const shapes = ` @prefix sh: . @prefix dclass: . @prefix dr: . a sh:NodeShape ; sh:targetClass dclass:ticket ; sh:property [ sh:path dr:reported_as ; sh:maxCount 1 ; sh:message "A ticket is reported as one bug at most." ] . `; await lbb.schema.publish({ desired_mode: "warn", shapes: { source: shapes, format: "turtle" } }); const report = await lbb.ontology.conformance({ consistency: "strong", maxRetries: 60 }); console.log(report.conforms, report.result_count, report.validated_at_seq); if (report.conforms) { await lbb.schema.publish({ desired_mode: "reject", shapes: { source: shapes, format: "turtle" } }); } ``` | Mode | Behavior | | --- | --- | | `off` | No check at write time. | | `warn` | Writes succeed. The conformance report lists violations. Use it while you add or repair a shape. | | `reject` | A write that breaks a shape returns 400, and nothing is stored. Use it after the report is clean. | After a publication, the platform validates the graph in the background. `ontology.conformance()` reads the stored report for the active shapes and takes no shapes of its own. A strong read returns a retryable 429 `strong_read_pending` until the report for new shapes exists. The client retries it after each `Retry-After`. The first report can take several seconds, so the example raises `maxRetries` from 6. A change of mode publishes the same shapes as a new version. `schema.publish(body, { dryRun: true })` previews a publication. [Choose an enforcement mode](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#choose-an-enforcement-mode) explains when to switch. `commitDryRun(body)` runs the same checks as a commit and writes nothing. Its `schema_validation` lists each violation with `component`, `focus_node`, `result_path` and `message`. An agent can read that list, fix its write and retry. ```ts const preview = await lbb.commitDryRun({ triplets: [{ source: login, relation: "REPORTED_AS", target: pdf }], }); console.log(preview.schema_validation?.conforms); ``` Ontology drafts keep a proposed schema apart from the graph. `ontology.drafts.create(body)` takes 1 to 100 sample records and stores the proposed operations, an analysis per competency question, coverage, pitfalls and confidence at one commit. It writes no facts. Continue with `ontology.drafts.validate(id)`, then `promote(id)` or `reject(id, reason)`. `promote` sends an idempotency key and is safe to retry. ### Change and delete data `graph(name).retract` removes current links as a new commit. `edges` names single links. `entities` removes every link of a record. `purge_entity_properties: true` also clears the record's fields, so it leaves search and SPARQL. Past commits keep the retracted data. ```ts const retracted = await lbb.graph("support").retract({ edges: [{ source: pdf, relation: "FIXED_IN", target: r311 }], }); console.log(retracted.commit_seq); ``` | Method | What it does | | --- | --- | | `graph(name).retract(body)` | Removes links, and optionally fields, as a new commit. | | `reload(lines, { confirm, dryRun? })` | Makes the graph match the NDJSON lines in one commit. Records missing from the lines leave current state. `confirm` is the graph name. `dryRun: true` previews the change and writes nothing. | | `forkGraph({ src, dst })` | Copies a graph into a new graph as a background job. The destination must be a new graph id. The id of a deleted graph is refused. Safe to retry. | | `createGraph()` | Creates the client's graph with an empty schema. `ontology.define` also creates it. | | `listGraphs()` | Lists the graphs of the environment. | | `graph(name).delete({ confirm })` or `deleteGraph({ confirm })` | Deletes the graph, its search feedback and its pending jobs. `confirm` is the graph name. Safe to retry. | A `reload` response carries `prior_commit_seq`. Read the graph at that commit with `as_of_commit_seq` to see the state before the reload. ```ts const planned = await lbb.reload(tickets, { confirm: "support", dryRun: true }); console.log(planned.prior_commit_seq); ``` ### Publication status After a commit, the platform publishes it for search and eventual reads. | Method | Returns | | --- | --- | | `waitForPublished(commitSeq, opts?)` | Resolves when the latest published commit covers `commitSeq`. Throws after `timeoutMs` (default 30,000) or when publication is `blocked`. Polls every `pollIntervalMs` (default 250), or later when the server sends a longer retry delay. | | `publicationStatus()` | `state` (`current`, `queued`, `compacting`, `building`, `publishing` or `blocked`), `head_seq`, `published_seq`, `lag_commits` and `retry`. | | `readSnapshot()` | `head_seq`, `snapshot.served_at_seq`, `query_lag_commits`, `base_current` and `conformance_lag_commits`. | | `summary(opts?)` | Record, link and type counts, with the commit they describe. | | `schemaSummary()` | The types and fields found in the published data, with counts. | | `plannerStats({ cursor?, limit? })` | The statistics the query planner uses: triple counts per link and field, and the indexes present. See [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). | | `metadata()` | The ontology version and storage size of the graph. | `base_current` is `true` when the published data reflects every commit and schema change. ```ts const published = await lbb.readSnapshot(); console.log(published.snapshot.served_at_seq, published.query_lag_commits, published.base_current); ``` ### Errors and retries Each method returns parsed JSON. On a non-2xx response, it throws `LbbError`. ```ts try { await lbb.graph("support").facts.create({ triplets: [{ source: login, relation: "BLOCKS", target: auth }], }); } catch (err) { if (!(err instanceof LbbError)) throw err; console.error(err.status, err.code, err.message); } ``` | Field | Meaning | | --- | --- | | `status` | The HTTP status. | | `code`, `type`, `message`, `param` | The error from the response body. | | `retryable`, `retryAfterSeconds` | Whether a retry can succeed, and when. | | `requestId`, `docUrl` | The request id to quote in support, and a link to the docs. | | `endpointHint` | For `stack_endpoint_required` and `stack_endpoint_mismatch`: what to fix. | | `details` | The reasons for each item of a refusal. An example is the `conflicts` of `409 starter_conflict`. | | `body` | The raw response body. | The client retries these requests: - GET, HEAD and OPTIONS requests. - Writes that carry an idempotency key: commits, imports, retracts, reloads, SPARQL updates, schema publications and draft promotions. The client adds the key when you omit it. - The read-only POST methods `query.sparql`, `query.sparqlRaw`, `query.structured`, `embeddings.search`, `ontology.search`, `ontology.resolve` and `ontology.drafts.validate`. - `embeddings.searchTuning.apply`, which sets the same settings again. - Graph deletes and forks. - A retryable `429` on `sparqlRows` and `sparqlText`. A `5xx` or a network failure is not retried, because the query could run twice. Other POST and PUT calls are sent once. Examples are `ontology.define`, `embeddings.declare` and `sparql`. Pass `{ retry: true }` to a namespace method to override this. `retry` is `true`, `false` or `"rate_limited"`, which retries a retryable `429` only. A retry follows a `429`, a `5xx` or a network failure. The delay is the `Retry-After` header, the body's `retry_after_seconds`, or a full-jitter exponential backoff. An error that the body marks `retryable: false` is thrown at once. `retryBudgetMs` bounds the total retry time, and `maxRetries` caps the count. Namespace methods take these options as their last argument: `timeoutMs`, `maxRetries`, `retryBudgetMs`, `retry`, `signal` and `headers`. Writes also take `idempotencyKey`. `rawRequest(method, path, opts)` returns the response with metadata: `requestId`, `version`, `headers`, `attempts`, `retryCount` and `elapsedMs`. `onRetry` receives each retry with its status, error code and delay. ```ts const response = await lbb.rawRequest("GET", "/v1/graph/summary"); console.log(response.status, response.requestId, response.attempts); ``` ### Types The package exports the types of every request and response. | Export | What it is | | --- | --- | | `Schemas["Name"]` | Any shape of the API contract, such as `Schemas["SearchResponse"]`. | | `Entity`, `EntitySelector`, `CommitRequest`, `CommitResponse`, `GraphSummary`, `GraphMetadata`, `SchemaView`, `Snapshot` | Short names for common shapes. | | `SparqlResults`, `SparqlTerm`, `SparqlResultsJson` | Parsed SPARQL results, one typed term, and the raw results document. | | `LbbClientOptions`, `CallOptions`, `RequestOptions`, `ReadConsistencyOptions` | The option types of the client and its methods. | | `components`, `paths`, `operations` | The raw OpenAPI types of the contract. | ```ts import type { Schemas } from "@littlebigbrain/client"; type Hit = Schemas["SearchHit"]; const best: Hit | undefined = found.hits[0]; ``` ### Method map | Area | Methods | | --- | --- | | Schema | `ontology.define`, `ontology.evolve`, `ontology.view`, `ontology.conformance`, `ontology.search`, `ontology.resolve`, `schema.view`, `schema.publish` | | Drafts | `ontology.drafts.create`, `ontology.drafts.get`, `ontology.drafts.validate`, `ontology.drafts.promote`, `ontology.drafts.reject` | | Ontology starters | `ontology.starters.list`, `ontology.starters.get`, `ontology.starters.apply`, `ontology.starters.update`; the `starters` export: `starters.crm`, `starters.documents`, `starters.work` | | Starter workflows | `workflows.starters.list`, `workflows.starters.use`, `workflows.triggered.preview`, `workflows.named.list`, `workflows.named.all`, `workflows.named.get`, `workflows.named.put`, `workflows.named.run`, `workflows.named.pause`, `workflows.named.queue`, `workflows.named.runs`, `workflows.named.allRuns`, `workflows.named.getRun`, `workflows.named.runStats`, `workflows.named.runResults`, `workflows.named.allRunResults`, `workflows.named.documents`, `workflows.named.allDocuments`, `workflows.named.delete`, `workflows.derivations.check`, `workflows.derivations.record`, `workflows.derivations.derive`; `graph(name).workflows` scopes each one to a graph; `triggeredWorkflow`, `writeOwned`, `parseTriggerMessage`, `entityRef` and `entityIdFromIri` for developer workflows | | Files | `files.upload`, `files.list`, `files.get`, `files.counts`, `files.content`, `files.contentStream`, `files.head`, `files.pages`, `files.delete` | | Suggestions | `ontology.suggestions.list`, `ontology.suggestions.get`, `ontology.suggestions.create`, `ontology.suggestions.validate`, `ontology.suggestions.accept`, `ontology.suggestions.dismiss`, `ontology.suggestions.supersede`, `ontology.suggestions.comment` | | Write | `graph(name).facts.create`, `commit`, `commitDryRun`, `graph(name).retract`, `reload` | | Import | `graph(name).facts.importRdf`, `importRdfMany`, `graph(name).facts.import`, `submitImport`, `getImportJob`, `waitForImportJob`, `cancelImportJob` | | Search | `embeddings.declare`, `embeddings.get`, `embeddings.list`, `embeddings.preview`, `embeddings.search`, `embeddings.setModel`, `embeddings.refresh`, `embeddings.delete`, `embeddings.searchSettings`, `embeddings.setSearchSettings`, `managedModels`, `modelActivity` | | Search tuning | `embeddings.searchTuning.start`, `embeddings.searchTuning.list`, `embeddings.searchTuning.get`, `embeddings.searchTuning.apply` | | Model checks | `checks.summary`, `checks.list`, `checks.review`, `checks.export`, `checks.calls`, `checks.call`, `checks.checkCall` | | Model choice | `models.trials.options`, `models.trials.create`, `models.trials.wait`, `models.trials.list`, `models.trials.get`, `models.trials.call`, `models.trials.stop`, `models.switches.create`, `models.switches.list`, `models.switches.revert`, `models.choices.list`, `models.choices.all`, `models.choices.put`, `models.choices.delete` | | Query | `sparqlRows`, `sparqlText`, `sparql`, `query.sparql`, `query.sparqlRaw`, `query.structured`, `query.update`, `query.ask`, `query.askStream`, `entities.filterByAttributes` | | Records | `entities.detail` | | Graphs | `createGraph`, `listGraphs`, `forkGraph`, `deleteGraph`, `graph(name).delete` | | Integrations | `integrations.connectors`, `integrations.providerApps`, `integrations.createConnectSession`, `integrations.getConnectSession`, `integrations.create`, `integrations.list`, `integrations.get`, `integrations.setCredentials`, `integrations.setSettings`, `integrations.sync`, `integrations.pause`, `integrations.resume`, `integrations.delete`, `integrations.erase`, `integrations.suggestions`, `integrations.accept`, `integrations.dismiss`; `googleDrive.authorizeUrl` and `googleDrive.exchangeCode` (imported from the package) | | Publication | `waitForPublished`, `publicationStatus`, `readSnapshot`, `summary`, `schemaSummary`, `metadata`, `status` | | Transport | `rawRequest`, `request`, `integrationsRequest`, `idempotencyKey`, `withScope`, `listAll` | `commit(body)` is the client-level form of `facts.create`. It also returns the number as `commitSeq`. The client has further methods for managed evals and model training. This page does not cover them. ### Native SPARQL protocol Each environment also serves the SPARQL 1.1 Protocol at `/sparql`. Standard tools such as YASGUI and Protégé connect there. It takes `GET ?query=`, a form POST, or an `application/sparql-query` body. The `Accept` header selects the format. SELECT and ASK return JSON, XML, CSV or TSV. CONSTRUCT and DESCRIBE return Turtle or N-Triples. `/sparql` accepts `consistency`, `min_indexed_seq`, `as_of_commit_seq` and `entailment` as query parameters. To export the graph as RDF, run a CONSTRUCT query there: ```ts const exported = await lbb.rawRequest("GET", "/sparql", { query: { query: "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" }, headers: { accept: "application/n-triples" }, }); console.log(exported.data.split("\n").length); ``` The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/#native-sparql-11-protocol) covers the protocol in full. ### Optional application helpers Start with the client above. The optional `catalog` and `access` helpers add namespace membership, exact-epoch adoption, bounded catalog pages and delegated read tokens. They require separately enabled server support. Shared-reference queries also require explicit access to every selected source. See [How to model your app](https://docs.littlebigbrain.com/start/model-your-app/) for boundaries and an example. ## Python — lbb URL: https://docs.littlebigbrain.com/sdks/python/ Source: docs-site/src/content/docs/sdks/python.mdx Description: The Python client for little big brain. Declare types and links, write records, search by meaning, follow links with SPARQL and replay each answer at its commit. Sync and async, with dict or typed responses. `littlebigbrain` is the Python client for little big brain. Use it in scripts, notebooks, data pipelines and Python services. Each method calls the [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/) and returns JSON as a `dict` or a typed model. This page uses the example of the [quickstart](https://docs.littlebigbrain.com/start/quickstart/): support tickets, bugs and releases. The sections from **Create a client** to **Pin a commit** form one program. Add each block to the end of one file and run it with `python3`. The reference sections after them cover the rest of the client. They include the async client and typed models. The package needs Python 3.10 or later and depends on `httpx` and `pydantic`. Source and issues: [github.com/littlebigbrains/lbb-python](https://github.com/littlebigbrains/lbb-python) (Apache-2.0). ### Install ```sh pip install littlebigbrain ``` The package imports as `lbb`. This page describes version 0.32.0. ### Create a client ```python import json import os import time from lbb import LbbClient, LbbError lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") ``` The first argument is the complete endpoint from the environment's **Connect** view in the console. `api_key` is an environment API key. `graph` names the graph that the client reads and writes. Without it, the server uses `main`. One client serves one graph. `lbb.graph("name")` returns `facts`, `retract`, `delete`, `publication_status` and `wait_for_published` for another graph. For the other namespaces, create a second client with `graph="name"`. The client holds an HTTP connection pool. Call `lbb.close()` when you are done. Or use the client as a context manager: `with LbbClient(...) as lbb:`. > [!CAUTION] Keep API keys on your server or your machine. Never ship a key to a browser or a mobile app. See [Authentication](https://docs.littlebigbrain.com/start/authentication/). | Argument | Default | Meaning | | --- | --- | --- | | `base_url` | `http://127.0.0.1:7400` | The environment endpoint. The default is a local server. | | `api_key` | none | An environment API key (`lbb_sk_test_…` or `lbb_sk_live_…`) or a single-mode token. It is sent as a bearer token. | | `graph` | the server's `main` | The graph, sent as `?graph=`. | | `environment` | none | Environment slug for an account-scoped session token. Uses the compatible `stack` wire parameter. | | `integrations_url` | `https://api.littlebigbrain.com` | The integrations API that `integrations` calls with the same key. See [Integrations for your customers](https://docs.littlebigbrain.com/guides/integrations-for-your-customers/). | | `default_consistency` | the server's `eventual` | The read consistency when a call names none. See [Read your own writes](#read-your-own-writes). | | `timeout` | `120.0` | The timeout of one attempt, in seconds. | | `retry_budget_ms` | `60000` | The total time that retries of one call may take. | | `max_retries` | `6` | The most retries of one call. | | `retry_delay` | `0.1` | The base delay of the backoff, in seconds. | | `on_retry` | none | A callback that receives a `RetryEvent` for each retry. | | `event_hooks` | none | `httpx` event hooks, for logs and metrics. | | `transport` | none | An `httpx` transport, for tests or proxies. | | `api_version` | `2026-10-21` | The value of the `lbb-version` header. An earlier date keeps the list shapes of that date; see [Lists](https://docs.littlebigbrain.com/sdks/http-api/#lists). | ### Declare types and links A graph accepts the record types, links and fields that you declare. A write that does not fit is refused. `ontology.define` declares types and links and creates the graph. `ontology.evolve` adds fields. ```python lbb.ontology.define({ "format": "spec", "merge_default": False, "source": json.dumps({ "entity_types": ["Ticket", "Bug", "Release"], "relation_types": [ {"name": "REPORTED_AS", "source": "Ticket", "target": "Bug"}, {"name": "FIXED_IN", "source": "Bug", "target": "Release"}, ], }), }) lbb.ontology.evolve({ "ops": [ {"op": "add_property", "name": "description", "value_type": "text"}, {"op": "add_property", "name": "priority", "value_type": "keyword"}, ], }) ``` Both calls are safe to repeat. `define` returns `changed=False` for a document the graph already has. It applies an additive change, such as a new type or a wider link. `define` refuses a change that narrows or removes what the graph declares. The error codes are `ontology_restrictive_change`, `ontology_identity_breaking_change` and `ontology_unsupported_change`. Nothing is written then. `merge_default: False` leaves out the built-in default types. `source` also accepts JSON-LD, Turtle, RDF/XML and CSV. `evolve(body, dry_run=True)` previews a change. [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/) covers parent types, inverse links and allowed values. ### Write records A write is a **commit**. `facts.create` takes links between records (`triplets`) and field values (`entity_properties`). A record is its `type` plus a `key`, and it has a `name`. A link can carry `evidence`, the reason it was recorded. ```python login = {"type": "Ticket", "key": "4821", "name": "login fails after 3.1"} invoice = {"type": "Ticket", "key": "4822", "name": "invoice PDF is blank"} csv_export = {"type": "Ticket", "key": "4823", "name": "CSV export times out"} auth = {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"} pdf = {"type": "Bug", "key": "BILL-7", "name": "PDF fonts missing"} slow_export = {"type": "Bug", "key": "DATA-58", "name": "export runs on one thread"} r32 = {"type": "Release", "key": "3.2", "name": "Release 3.2"} r311 = {"type": "Release", "key": "3.1.1", "name": "Release 3.1.1"} written = lbb.graph("support").facts.create({ "triplets": [ {"source": login, "relation": "REPORTED_AS", "target": auth, "evidence": "Support triage, 2 Sep"}, {"source": invoice, "relation": "REPORTED_AS", "target": pdf, "evidence": "Support triage, 3 Sep"}, {"source": csv_export, "relation": "REPORTED_AS", "target": slow_export, "evidence": "Support triage, 4 Sep"}, {"source": auth, "relation": "FIXED_IN", "target": r32, "evidence": "Release notes 3.2"}, {"source": pdf, "relation": "FIXED_IN", "target": r311, "evidence": "Release notes 3.1.1"}, ], "entity_properties": [ {**login, "properties": {"priority": "high", "description": "Since 3.1, people are sent back to the login screen right after they type their password."}}, {**invoice, "properties": {"priority": "low", "description": "Downloaded invoices open as empty pages."}}, {**csv_export, "properties": {"priority": "low", "description": "Exports with more than 10,000 rows stop after 30 seconds."}}, ], }, idempotency_key="sdk-support-1") lbb.wait_for_published(written["commit_seq"]) print("commit", written["commit_seq"]) ``` The response reports `commit_seq`, the number of the commit. `wait_for_published` waits until the latest published data covers that commit. Search and default reads use published data. This usually takes a second or two. Each write carries an idempotency key. The client creates one per call, and its own retries of that call reuse it. Pass your own `idempotency_key` so that a rerun of your code writes nothing new. A second call with the same key returns the first result with `replayed: True`. This holds even when the payload changed, so use a new key for new data. Field writes merge: a commit that sends one field keeps the other fields. An unknown type, link or field fails the whole commit. So does a value of the wrong type. A commit body is at most 1 MiB. For more, use [NDJSON import](#import-ndjson). ### Turn on search Declare an **embedding** on a type and name the fields to embed. The platform builds one text per record, embeds it and keeps the vectors current. The first build runs in the background. ```python TICKET = "https://littlebigbrain.com/class/ticket" lbb.embeddings.declare(TICKET, from_=["label", "description"]) while not lbb.embeddings.get("ticket").get("serving"): time.sleep(2) ``` `TICKET` is the global name of the type: its name in lowercase after `https://littlebigbrain.com/class/`. `label` is the record's `name`. The embedding's name defaults to the last part of the class, here `ticket`. `from_` is the wire field `from`. `declare` also takes `name`, `exclude`, `title`, `model` and `dim`. The namespace has `list`, `preview`, `set_model`, `refresh` and `delete` too. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) covers each one, with the errors and the costs. ### Search by meaning ```python found = lbb.embeddings.search("users cannot sign in after the upgrade", top_k=3) for hit in found["hits"]: print(f"{hit['score']:.3f}", hit["label"]) ``` Each hit has an `iri`, a `label`, a `score` and a `class`. The `iri` is the global name of the record. `served_at_seq` is the commit that the search checked each hit against. `filter_` keeps hits by type and by link. `include=["text"]` returns the embedded text of each hit. `explain=True` plans the search and calls no model. `embedding` searches one embedding by name. ### Starter workflows A starter workflow is a template for work that follows a type. A workflow created from it reads, after each published commit, the records that the commit added or changed, and does its work. An embedding is a workflow of the starter workflow `search.embed`; fit from text is `ontology.fit`. ```python lbb.use_workflow_starter("search.embed", class_=TICKET, fields=["label", "description"]) ticket = lbb.workflows.named.get("ticket") print(ticket.status.state, ticket.status.lag_commits) for workflow in lbb.workflows.named.all(): print(workflow.name, workflow.starter) ``` `class_` and `fields` form the wire field `watch`; `params` takes the starter workflow's settings as a dict. `workflow_starters()` lists the starter workflows the server offers, and `use_workflow_starter()` creates a workflow from one or changes it. `preview_triggered_workflow()` shows what creating one would do without writing anything (`sample=`, `iris=` and `propose=` choose what it shows). These methods return the JSON answers, and `AsyncLbbClient` and `graph(name)` have them too. `lbb.workflows.named` reads and changes the workflows of the graph by name and returns the generated models: `list(limit=, cursor=)` returns one page in the [list envelope](https://docs.littlebigbrain.com/sdks/http-api/#lists) and `all()` iterates every workflow, `get(name)` reads one, `put(body)` creates or changes a workflow of your worker, `run(name)` asks it to run now, `pause(name, paused=)` pauses or resumes it, and `delete(name, confirm=name)` deletes it. `queue(name, cursor=, limit=)` lists what its trigger has not sent yet, `runs(name, cursor=, limit=, outcome=)` and `all_runs(name)` its runs, `get_run(name, run)` one run, `run_stats(name, hours=)` its runs by hour, `run_results(name, run=, outcome=, iri=, cursor=, limit=)` and `all_run_results(name)` what its runs did with each input, and `documents(name, q=, sort=, cursor=, limit=)` and `all_documents(name)` the documents of a document workflow. On `AsyncLbbClient` each method is awaited, and `all()`, `all_runs()`, `all_run_results()` and `all_documents()` are async iterators. [Starter workflows](https://docs.littlebigbrain.com/guides/starter-workflows/) covers the fields, the status and the errors. A developer workflow (a workflow of `workflow`) sends the records to a workflow of your worker. Create it with `workflow=`, `owns=` and `batch=`, and pause or resume it with `workflows.named.pause()`: ```python lbb.use_workflow_starter( "workflow", name="word-count", class_="Note", fields=["body"], workflow={"workflow_type": "word-count", "version": "v1"}, owns={"properties": ["word_count"]}, batch=16, ) lbb.workflows.named.pause("word-count") lbb.workflows.named.pause("word-count", paused=False) ``` Your code writes what it owns with `replace_owned` in the body of `commit` (or `graph(name).facts.create`); the answer reports `owned`. `ontology.suggestion_create` sends `facts`, and an accepted suggestion has `facts_commit`. [Developer workflows](https://docs.littlebigbrain.com/guides/starter-workflows/#developer-workflows) covers the message and the rules. A workflow that works on files keeps a derivation per file, so the same bytes under a second name are worked on once. `check_derivation(sha256, workflow_type, version, settings_digest)` answers `exists` and the upload of the live file, `content_uploaded_at`. `record_derivation(..., run_id=, outputs=, content_uploaded_at=)` keeps the first record of each key. With the upload of the check, a record of a file deleted since then writes nothing. It raises `LbbError` 409 `derivation_upload_changed`. The run of a new upload does the work: ```python from lbb import LbbError key = (message["origin"]["sha256"], "pdf-parse", "v1", "none") checked = lbb.check_derivation(*key) if not checked["exists"]: pages = parse(key[0]) try: lbb.record_derivation( *key, run_id=message_id, outputs={"pages": pages}, content_uploaded_at=checked.get("content_uploaded_at"), ) except LbbError as error: if error.code != "derivation_upload_changed": raise ``` A message names instances by IRI. `entity_ref(iri, type, name=None)` turns one into the commit reference of that record (its `key` is the id sentinel and the 32-hex id), so a commit reaches a keyed record too: ```python from lbb import entity_ref subjects = [entity_ref(iri, "Note") for iri in message["instances"]] ``` In `replace_owned` only the id counts. A link endpoint or a field row writes its name as the record's display name, so pass the record's name there. `entity_id_from_iri(iri)` returns the id alone. ### Run a workflow worker `lbb.workflows` runs the turns of your workflows, with the rules of the TypeScript worker. Steps save their outputs, so a turn that runs again replays them. `WorkflowWorker` runs on asyncio, at most `concurrency` turns at once. On SIGTERM it drains its held turns and releases the rest. `triggered_workflow` and `write_owned` serve developer workflows, and `lbb_activities(client)` calls LBB's models from a step. The worker records what each run did with each input from `write_owned`, `review` and `requeue`, and sends it with the turn's completion ([Run results](https://docs.littlebigbrain.com/guides/workflows/#run-results)). [Run a worker in Python](https://docs.littlebigbrain.com/guides/python-worker/) shows each part. ### Files The document workflows read the files of a graph. `files` uploads them and reads what the parse made of them: ```python answer = lbb.files.upload("manual.pdf") sha = answer["file"]["sha256"] files = lbb.files.list()["files"] pages = lbb.files.pages(sha, from_=0, to=2) for line in pages["pages"][0]["lines"]: print(line["id"], line["box"], line["text"]) ``` `upload` takes bytes with `name=`, or the path of a file (its name and its content type default to the path's), of at most 1 GiB. A path is sent from the file in chunks, never held whole. The same bytes under the same name again change nothing, so a failed call is retried. `language=` (the natural language of the content, a BCP 47 tag such as `en` or `de-CH`, at most 64 characters) and `schema=` (a name or a URL, at most 256) describe the content; the file's `Document` stores each one given. `list()` returns the newest 1,000 files; `list(limit=, after=)` reads one page (`limit` 1 to 1,000), and the answer's `next` is the `after` of the next page, absent at the end. `get(sha256)` reads one file's record, and `counts()` counts the files by parse status, as of the commit the published generation holds. `content(sha256)` returns the bytes, and `delete(sha256)` repeats the SHA-256 as `confirm`. `content` holds the whole answer. Write a large file to disk as it arrives with `download`, ask for one byte range with `range=`, and read a file's size, type and SHA-256 without its bytes with `head`: ```python written = lbb.files.download(sha, "manual.pdf") # the bytes it wrote last_kib = lbb.files.content(sha, range=(None, 1024)) first_kib = lbb.files.content(sha, range=(0, 1023)) info = lbb.files.head(sha) # {"bytes", "content_type", "sha256"} ``` A range is `(start, end)` with both bytes included, `(start, None)` to the end, or `(None, n)` for the last `n` bytes. A range with no byte of the file raises `LbbError` 416 `range_not_satisfiable`. A page range is 0-based with `to` excluded. Also on `AsyncLbbClient` and `graph(name).files`. [From documents to a graph](https://docs.littlebigbrain.com/guides/documents-to-graph/) creates the document workflows and reads the links they write. ### Follow links with SPARQL Search finds a starting point. A graph query follows the stored links from it to the answer. The query language is SPARQL 1.1, the W3C standard for graph queries. [Core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql) explains the syntax. ```python ticket = found["hits"][0]["iri"] read_at = found["served_at_seq"] query = f""" PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE {{ <{ticket}> dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . }}""" rows = lbb.sparql(query, as_of_commit_seq=read_at).rows() print(rows, "read at commit", read_at) ``` With a real embedding model, the login ticket comes first. On a new graph, the program then prints: ```text [{'bug': 'session token expiry', 'release': 'Release 3.2'}] read at commit 1 ``` `as_of_commit_seq` runs the query at the commit the search read. Search and query then agree on one version of the data. ### Pin a commit Store `read_at` with the answer. Later writes do not change that commit. The same query with the same `as_of_commit_seq` returns the same rows while the commit is retained. ```python r321 = {"type": "Release", "key": "3.2.1", "name": "Release 3.2.1"} hotfix = lbb.graph("support").facts.create({ "triplets": [{"source": auth, "relation": "FIXED_IN", "target": r321, "evidence": "Release notes 3.2.1"}], }, idempotency_key="sdk-support-2") lbb.wait_for_published(hotfix["commit_seq"]) latest = lbb.sparql(query) replay = lbb.sparql(query, as_of_commit_seq=read_at) print(len(latest), len(replay)) ``` With the login ticket as the first hit, this prints `2 1`. The latest data names two releases. The replay reads commit 1, which names one. A pin past the newest commit returns 400 `invalid_request`. A commit that is no longer retained returns 400 `historical_generation_unavailable`. The server never answers from a nearby commit instead. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) covers retention. ### SPARQL `lbb.sparql(query, …)` runs a SPARQL 1.1 SELECT or ASK query. It returns `SparqlResults`. The examples in this and the next sections use the `support` graph from the program above. ```python results = lbb.sparql(""" SELECT ?ticket ?priority WHERE { ?t ?ticket ; ?priority . } ORDER BY ?ticket """) print(results.vars) for row in results: print(row["ticket"], row["priority"]) print(lbb.sparql("ASK { ?t ?b }").boolean) ``` | Attribute | Meaning | | --- | --- | | `vars` | The selected variable names. | | `rows()` | Each binding as a `{variable: value}` dict. Iterating the results yields the same dicts, and `len()` counts them. | | `bindings` | The typed terms, with `type`, `value`, `datatype` and `xml:lang`. | | `boolean` | The answer of an ASK query, and `None` for SELECT. | | `row_page` | `returned`, `total`, `has_more` and the paging window. | | `snapshot` | For an eventual or pinned read, the commit it read as `served_at_seq`. `None` for a plain strong read. | | `next_cursor` | The cursor for the next page, when you asked for cursor pages. | | `trace_id` | The eval trace the server recorded, when you passed `request`. | | `search` | How a search inside the query ran. See [Search inside a query](#search-inside-a-query). `None` for a query without a search. | | `profile` | What the server measured, when you passed `profile=True`. | `sparql` takes these keyword arguments: | Argument | Meaning | | --- | --- | | `as_of_commit_seq` | Run the query at this retained commit. | | `entailment` | `"none"` (default), `"subclass"`, `"rdfs"` or `"owl"`. The others add facts that follow from the schema. See [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/). | | `limit`, `offset` | Return a window of the result rows. | | `cursor` | Pages that stay on one commit. `""` starts, and `next_cursor` continues. The query needs one indexed, ordered `LIMIT` of 1 to 10,000 and no `OFFSET`. Other queries return 400 `invalid_pagination_cursor`; page them with `limit` and `offset`. | | `consistency`, `min_indexed_seq` | See [Read your own writes](#read-your-own-writes). | | `request` | The user's words behind the query. The server records an eval trace, and `trace_id` names it. | | `profile` | `True` returns the server's measurements in `profile`. | | `reason` | `True` returns a typed error. Stored rules already run when data is published. | `query.sparql(...)` is the same method. CONSTRUCT and DESCRIBE return RDF documents, so `sparql` refuses them with 400. Run them on the native `/sparql` endpoint. See [Native SPARQL protocol](#native-sparql-protocol). #### Search inside a query A query can find records by meaning. The pattern `?x search:similarTo "text"` binds the records nearest to the text. The rest of the query then filters, joins and orders them like other rows. `search:score` binds the score. The prefix `search:` is `https://littlebigbrain.com/search#`, and the graph needs an [embedding](#turn-on-search). ```python near = lbb.sparql(""" PREFIX search: PREFIX rdfs: SELECT ?ticket ?score WHERE { ?t search:similarTo "sign-in fails" ; search:score ?score ; rdfs:label ?ticket . } ORDER BY DESC(?score) LIMIT 3 """) print(near.rows(), near.search["plan"], near.search["complete"]) ``` `search` reports how the search ran: the `plan` (`nearest`, `filter_first` or `search_first`), the hits asked for (`top`) and bound (`hits`), `complete`, and `lag_commits`, how far the vectors trail the commit the query read. [Search inside a SPARQL query](https://docs.littlebigbrain.com/guides/search-by-meaning/#search-inside-a-sparql-query) lists the other patterns and explains the plans. A search query with `cursor` returns 400. Page it with `search:top` and `LIMIT`. > [!TIP] Global names are lowercase The types, links and fields you declare have global names such as ``, `` and ``. The last part is always lowercase. An uppercase name is a different IRI and matches nothing. IRIs that you load with `import_rdf` keep their original form. ### Questions in plain words `lbb.query.ask(question)` answers a question in plain words. The server describes the graph. A decision model picks the kind of question. A reasoning model runs queries in a short loop, reads their rows, and answers. ```python result = lbb.query.ask("Which release fixed the login bug?") if result.answer is None: print("the loop stopped:", result.error) print(result.answer, result.citations) for row in result.rows: print(row) ``` | Method | Returns | | --- | --- | | `query.ask(...)` | Sends the question to `POST /v1/query/ask`. The keyword arguments are `context`, `route`, `mode`, `limit`, `as_of_commit_seq`, `today`, `include_grounding`, `anchor` (the IRIs of records the user picked) and `timeline` (the commit of each date). The result has `answer` (the text), `citations`, `chart` (how to draw the rows, or `None`), `steps`, `rows` and `vars` of the query that holds the answer, `route`, `query`, `rationale`, `error`, `trace_id`, `linked` (the names the server found), `anchors` and `history`. `response` holds the whole response. `mode="route"` returns the route alone. | | `query.ask_stream(...)` | Takes the arguments of `query.ask` and yields one event per step: `grounding`, `route`, `step` per tool call, `answer`, and last `done` with the response. An `error` event raises `LbbError`. Close the generator to stop the server's work. | | `query.names(text, limit=...)` | The records a question or a list of names names: `candidates` with `text`, `iri`, `label`, `class`, `score` and `by`, the one to prefer first. No model call. | | `query.describe(question=..., classes=..., properties=...)` | The classes and properties a question needs, with how many sampled instances hold each property and the values of small classes, as JSON and as `text`. No model call. | | `query.commit_at(date=...)` or `query.commit_at(moment=...)` | The commit of a date or a moment: `as_of_commit_seq`, `committed_at` and `resolved_by`, or a `note`. | | `query.compare(query, before=..., after=..., key=..., max_rows=..., limit=..., cursor=...)` | Runs one `SELECT` at two points and returns `added`, `removed` and `changed` with `totals`, `truncated` and `next_cursor`. | The async client has the same methods: coroutines, and an async generator for `ask_stream`. `trace_id` names the eval trace of the query that holds the answer, with the question as its request. [Answer questions in plain words](https://docs.littlebigbrain.com/guides/answer-questions/) covers the routes, the options and the limits. It also lists the data that goes to the model providers. ### Model checks and search tuning A judge model checks a sample of the model calls LBB makes for a graph: the search rerank, the query router and rewriter, the ontology fit and the eval labels. `lbb.checks` reads the checks. A person can agree with a check or correct it, and the review becomes the call's ground truth. [Check model quality](https://docs.littlebigbrain.com/guides/check-model-quality/) explains the jobs and the scores. ```python month = lbb.checks.summary() print(month["judge"], month["jobs"]) checks = lbb.checks.list(verdict="wrong", reviewed=False)["checks"] for check in checks: print(check["call"], check["summary"], check["judge"]["reason"]) lbb.checks.review( checks[0]["call"], agree=False, verdict="right", note="The first hit answers the question.", ) ``` | Method | Returns | | --- | --- | | `checks.summary(month=)` | One month per job and model: the checks, the score, right, partly and wrong, and the reviews. Also the judge's agreement with people and today's budget. | | `checks.list(...)` | The month's checks, newest first. `job`, `month`, `verdict`, `reviewed`, `after` and `limit` filter and page. | | `checks.review(call_id, agree=, ...)` | `agree=True` keeps the judge's verdict. `agree=False` with `verdict` corrects it, with an optional `score`, `reference` and `note`. Returns the check. | | `checks.export(job=, month=)` | The month's checks as a list of parsed JSON lines, each `{"call": ..., "check": ...}`. | | `checks.calls(...)` | The call log, newest first. `job`, `day`, `checked`, `after` and `limit` filter and page. | | `checks.call(call_id)` | One call with its input, its output and its check. | | `checks.check_call(call_id)` | Asks the judge to check one call now. Returns `{"queued": True}`. | A search tuning session tests other search settings on the graph's own searches. The judge grades the hits, and the session proposes the settings that rank best. The settings change only when you apply the proposal. ```python session = lbb.embeddings.search_tuning.start() while session["status"] in ("queued", "running"): time.sleep(10) session = lbb.embeddings.search_tuning.get(session["id"]) if session.get("proposal"): lbb.embeddings.search_tuning.apply(session["id"]) ``` `search_tuning.list(limit=)` lists the sessions, newest first. `embeddings.set_search_settings` changes the settings by hand. It takes `rerank`, `rerank_depth`, `blend` and `probe_factor`. A setting you leave at `None` keeps its value. `lbb.RESET` sets one back to its default, and the client sends it as JSON `null`: ```python from lbb import RESET lbb.embeddings.set_search_settings(rerank_depth=60, blend=RESET) ``` `checks.check_call` and `search_tuning.start` spend the platform's judge budget. The client sends them once unless you pass `options={"retry": True}`. The async client has every method as a coroutine. ### Model choice: test and switch models Each use of a model on a graph (question answers `ask`, query routing `route`, search rerank `rerank`, ontology fit `fit`, eval labels `label`) runs one model. A trial tests another model on the use's checked calls and scores both models against the same ground truth. When the candidate meets the bar, switch the use to it, and revert in one step. These methods return typed models, so you read `trial.report.qualifies`, not dictionary keys. [Check model quality](https://docs.littlebigbrain.com/guides/check-model-quality/) explains the scores and the bar. ```python # What each use runs now, and the models you can test. options = lbb.models.trials.options() # Test Claude Haiku 5.5 at low effort on question answers. started = lbb.models.trials.create( candidate={"provider": "anthropic", "model": "claude-haiku-5-5", "effort": "low"}, jobs=["ask"], ) # The trial runs on the server. Wait until it compared the checked calls. trial = lbb.models.trials.wait( started.trials[0].id, on_update=lambda t: print(f"{t.report.calls} calls compared"), ) report = trial.report print(report.current.score, report.candidate.score, report.ci_low, report.ci_high) if report.qualifies: lbb.models.switches.create(trial=trial.id) # Back to LBB's model. lbb.models.switches.revert("ask") ``` With `AsyncLbbClient`, every method, `wait` too, is awaitable. | Method | Returns | | --- | --- | | `models.trials.options()` | Per use: the model it runs now, LBB's model, a switch, its checked calls, and the models you can test with their efforts and prices. | | `models.trials.create(candidate=, jobs=, target=, days=)` | `trials` and `skipped`: one trial per use. An open trial of the same use and candidate comes back as it is. `candidate` is a dict or a `ModelTrialModel`. | | `models.trials.wait(trial_id, until=, timeout=, poll_interval=, on_update=)` | The trial once it compared what it can (`until="compared"`, the default) or ended (`until="ended"`). | | `models.trials.list(job=, limit=)` | The trials, newest first, with their reports. | | `models.trials.get(trial_id)` | One trial with its report and its compared calls. | | `models.trials.call(trial_id, call_id)` | One compared call: both answers and the ground truth. | | `models.trials.stop(trial_id)` | Stops a trial. It keeps what it compared. | | `models.switches.create(trial=)` | Switches the trial's use to its candidate. | | `models.switches.list()` | The switched uses. | | `models.switches.revert(job)` | Puts the use back on LBB's model. | | `models.choices.list(workflow=, use=, limit=, cursor=)` | The model of each workflow step (`extract`, `decide`, `embed`): the environment's rows, and with `workflow` the row in force for each step of that workflow, with the models each can run (`options`). `all` pages through it. | | `models.choices.put(use=, model=, workflow=, idempotency_key=)` | Chooses the model of a step for the environment or one workflow. The next calls run it. The client sends an idempotency key unless you pass one. | | `models.choices.delete(use=, workflow=, idempotency_key=)` | Removes a choice: the step runs the environment's model, else LBB's. Returns `ModelChoiceDeleted` (`deleted` is true). | A report gives both models' right share, mean score (0 to 1), cost per call and median time; the difference with its 95% interval (`delta`, `ci_low`, `ci_high`); and `qualifies`: at least 20 calls, at most 5 points worse at the low end, no more failures, and cheaper or better. A switch refuses a trial that does not qualify (`trial_not_qualified`) or that compared with another model than the one the use runs now (`trial_outdated`): start a new trial. Catch them as `LbbError` and read `error.code`. ### Read your own writes Reads take a `consistency` argument: - `"eventual"` is the default. It reads the latest published commit, and the response's `snapshot["served_at_seq"]` names it. - `"strong"` also applies the newest writes. Use it to read your own write at once. Its response has no `snapshot` block. `min_indexed_seq` sets a floor: the read must cover that commit or a later one. A floor without a `consistency` reads strong. ```python note = lbb.graph("support").facts.create({ "entity_properties": [{**invoice, "properties": {"priority": "high"}}], }) now = lbb.sparql( "SELECT ?p WHERE { ?t ?p }", consistency="strong", min_indexed_seq=note["commit_seq"], ) print(len(now)) ``` Publication may not cover the floor yet. Then an eventual read returns 429 `read_your_writes_pending` with a `Retry-After`. `sparql` retries it within the retry budget. You can also call `wait_for_published` first. ```python pending = lbb.sparql( "SELECT ?p WHERE { ?t ?p }", consistency="eventual", min_indexed_seq=note["commit_seq"], ) print(pending.snapshot["served_at_seq"]) ``` Use `"strong"` when you need the answer now. Use `"eventual"` with a floor when you must record which commit you read. `consistency` and `min_indexed_seq` work on `sparql`, `sparql_select`, `summary`, `query.sparql` and `query.structured`. `ontology.conformance` and `entities.detail` take `consistency` only. `entities.detail(...)` reads one record: its typed `attributes` and its current links in `rdf_relations`. Name it by `id`, by `type` and `name`, or by `type` and `key`. `as_of_commit_seq` reads it at a retained commit. ```python record = lbb.entities.detail(type="Ticket", key="4821", consistency="strong") print(record["attributes"], len(record["rdf_relations"]["outgoing"])) ``` ### Load RDF If your data is RDF already, load it as it is. `facts.import_rdf` sends one document in N-Triples, Turtle, N-Quads or TriG. [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/) walks through a full import. ```python turtle = """ @prefix ex: . @prefix rdfs: . ex:auth-service rdfs:label "auth-service" ; ex:writesTo ex:user-db . ex:user-db rdfs:label "user-db" . """ imported = lbb.graph("catalog").facts.import_rdf( turtle, format="turtle", base_iri="https://example.org/", idempotency_key="catalog-2026-09-25", ) print(imported["triples_read"], imported["committed_commit_seq"]) ``` The first import creates the graph with a fixed generic schema, so you declare nothing first. Source IRIs keep their original form in queries. The response reports `triples_read`, `committed_commit_seq`, `error_count` and the first `errors`. | Argument | Meaning | | --- | --- | | `format` | `"ntriples"` (default), `"turtle"`, `"nquads"` or `"trig"`. | | `base_iri` | The base for relative IRIs. | | `blank_node_scope` | Scopes blank-node labels to one source document. An ASCII token of letters, digits and `-_.:/`. | | `batch` | Statements per internal commit. Default 100,000, maximum 1,000,000. | | `strict` | `True` fails the request at the first bad statement. By default the importer skips it and reports it in `errors`. | | `observed_at` | An RFC 3339 time to record on every statement. | | `build` | `False` holds back publication for this request. | | `idempotency_key` | The key for this request. The client creates one if you omit it. | | `graph_uri` | Refused with 400. Each graph serves the default RDF graph only. | Keep each request under 64 MiB. N-Triples and N-Quads have one statement per line. Split a large file on line boundaries. Turtle and TriG requests must each be a complete document. Omit `blank_node_scope` to keep blank-node labels the same across chunks and retries. Reuse one scope for every chunk of one source document. Documents that reuse the same labels then stay apart. `facts.import_rdf_many([doc, …], …)` imports several documents in order. Only the last request triggers publication. It returns a dict with `imports`, `final_sequence` and `publication`. Pass `final_sequence` to `wait_for_published`. With an `idempotency_key`, document `n` uses the key suffixed with `:n`. `lbb.import_rdf(...)` and `lbb.import_rdf_many(...)` do the same for the client's graph. `query.update(text, idempotency_key=None)` runs SPARQL Update on the `/update` endpoint of the client's graph. The server accepts `INSERT DATA`, and one request is one commit. Other update forms return 400. The client sends an idempotency key, so a retry replays the write. The answer has no body, so read the new facts with a `strong` read. ```python catalog = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="catalog") catalog.query.update( 'INSERT DATA { "gold" }', idempotency_key="catalog-tier-1", ) ``` A graph whose first write is RDF only takes RDF writes. On it, `commit`, `facts.create`, `commit_dry_run`, `import_ndjson`, `retract` and `reload` return 400 `rdf_native_graph`. A durable import job fails. Write JSON records first when you need both. ### Import NDJSON For many records, send newline-delimited JSON. Each line is a triplet, or a record with `type`, `name`, an optional `key` and `properties`. `facts.import_ndjson(lines)` imports in one request of at most 64 MiB. The server splits the lines into bounded commits and returns `committed_commit_seq`, the last one. ```python tickets = [ {"type": "Ticket", "key": "4824", "name": "search returns stale results", "properties": {"priority": "high"}}, {"source": {"type": "Ticket", "key": "4824", "name": "search returns stale results"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "SRCH-9", "name": "cache not invalidated"}}, ] sync = lbb.graph("support").facts.import_ndjson(tickets, idempotency_key="tickets-4824") print(sync["committed_commit_seq"]) ``` For a large dataset, use `submit_import_ndjson`. It streams a list, an iterable or a generator into a durable import job. It never holds the whole dataset in memory. The job runs on the server and survives a lost connection. ```python def records(): yield {"type": "Ticket", "key": "4825", "name": "PDF export fails", "properties": {"priority": "low"}} accepted = lbb.submit_import_ndjson(records(), idempotency_key="tickets-2026-09-25") status = lbb.wait_for_import_job(accepted.job_id) if status.state.value != "succeeded": raise RuntimeError(status.failure.message if status.failure else status.state) print(status.committed_commit_seq.root) ``` - `idempotency_key` is required. It binds the key to the uploaded content. - The client uploads once and does not retry the stream. To retry, call again with a fresh iterable and the same key. - An empty source raises `ValueError` before any request. - `get_import_job(job_id)` reads the status, and `cancel_import_job(job_id)` cancels the job. - `wait_for_import_job` polls every second. It takes `timeout` and `poll_interval` in seconds, and raises `TimeoutError` after `timeout`. These methods return typed models. The durable route needs the server capability `durable_import_jobs_v1`. The client checks `GET /version` first and raises `LbbCapabilityError` if it is missing. A request is at most 32 GiB. Both methods take `batch`, the records per internal commit, up to 10,000. The default is 1,000 for `import_ndjson` and 10,000 for a job. `strict=True` fails at the first malformed line, which is skipped by default. `observed_at` records an RFC 3339 time on every record. The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/) documents the job states and failure fields. ### Structured queries `sparql_select(body)` is a SELECT or ASK query written as JSON. It takes link patterns, typed filters, `group_by`, aggregates, `having` and `order_by`. Use it when you build a query from user input and want no query text. ```python pairs = lbb.sparql_select({ "patterns": [{"subject": {"var": "ticket"}, "predicate": "REPORTED_AS", "object": {"var": "bug"}}], "select": ["ticket", "bug"], "limit": 50, }) print(len(pairs["solutions"])) ``` `query.structured(body)` sends the same request, returns a typed model and retries a retryable `429`. `entities.filter_by_attributes` builds that body for you. The `patterns` bind variables through links. `where` compares declared fields on those variables. `op` is `eq` (default), `ne`, `lt`, `le`, `gt` or `ge`. ```python urgent = lbb.entities.filter_by_attributes( patterns=[{"subject": {"var": "ticket"}, "predicate": "REPORTED_AS", "object": {"var": "bug"}}], where=[{"var": "ticket", "field": "priority", "value": "high"}], select=["ticket", "bug"], ) print(len(urgent["solutions"])) ``` A value can also be `{"dateTime": "…"}` or `{"entity": selector}`. `group_keys` groups by a field value or by a calendar bucket of a date field. See [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/) for the full request. A body can be a `dict` or a model from `lbb.models`. ### Schema and SHACL `ontology.view()` returns the declared types, links and fields of the graph. With `counts=True`, each link also reports how many current links it has. For rules across fields and links, publish SHACL shapes, the W3C standard for validation rules. `schema.publish` stores the shapes and a mode. `schema.view()` returns the active shapes and mode. ```python shapes = """ @prefix sh: . @prefix dclass: . @prefix dr: . a sh:NodeShape ; sh:targetClass dclass:ticket ; sh:property [ sh:path dr:reported_as ; sh:maxCount 1 ; sh:message "A ticket is reported as one bug at most." ] . """ lbb.schema.publish({"desired_mode": "warn", "shapes": {"source": shapes, "format": "turtle"}}) report = lbb.ontology.conformance(consistency="strong", options={"max_retries": 60}) print(report.conforms, report.result_count, report.validated_at_seq.root) if report.conforms: lbb.schema.publish({"desired_mode": "reject", "shapes": {"source": shapes, "format": "turtle"}}) ``` | Mode | Behavior | | --- | --- | | `off` | No check at write time. | | `warn` | Writes succeed. The conformance report lists violations. Use it while you add or repair a shape. | | `reject` | A write that breaks a shape returns 400, and nothing is stored. Use it after the report is clean. | After a publication, the platform validates the graph in the background. `ontology.conformance()` reads the stored report for the active shapes and takes no shapes of its own. A strong read returns a retryable 429 `strong_read_pending` until the report for new shapes exists. The client retries it after each `Retry-After`. The first report can take several seconds, so the example raises `max_retries` from 6. A change of mode publishes the same shapes as a new version. [Choose an enforcement mode](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#choose-an-enforcement-mode) explains when to switch. `commit_dry_run(body)` runs the same checks as a commit and writes nothing. Its `schema_validation` lists each violation with `component`, `focus_node`, `result_path` and `message`. An agent can read that list, fix its write and retry. ```python preview = lbb.commit_dry_run({ "triplets": [{"source": login, "relation": "REPORTED_AS", "target": pdf}], }) print(preview["schema_validation"]["conforms"]) ``` `ontology.evolve` takes typed operations from `lbb.models`, each with an `op` field. Examples are `AddEntityTypeOp`, `AddRelationOp`, `AddPropertyOp` and `WidenRelationOp`. The models also cover the operations that narrow, rename or remove. `AdditiveOntologyEvolveRequest` accepts four operations: `add_entity_type`, `add_relation`, `add_super_types` and `widen_relation`. Use it when a model or an agent proposes changes and must not narrow the schema. With `dry_run=True`, `evolve` returns the typed diff: `applied`, `conflicts`, `publishable`, `no_op` and the next `ontology_version`. Nothing is written. ```python from lbb.models import AddEntityTypeOp, AdditiveOntologyEvolveRequest change = AdditiveOntologyEvolveRequest(ops=[AddEntityTypeOp(op="add_entity_type", name="Component")]) diff = lbb.ontology.evolve(change, dry_run=True) print(diff.publishable, diff.no_op, diff.ontology_version) ``` Ontology drafts keep a proposed schema apart from the graph. `ontology.draft_create(...)` stores sample evidence, competency-question analyses, proposed operations, coverage, pitfalls and confidence at one commit. Continue with `draft_validate`, then `draft_promote` or `draft_reject`. `draft_promote` takes an idempotency key and is safe to retry. ### Change and delete data `graph(name).retract` removes current links as a new commit. `edges` names single links. `entities` removes every link of a record. `purge_entity_properties: True` also clears the record's fields, so it leaves search and SPARQL. Past commits keep the retracted data. ```python retracted = lbb.graph("support").retract({ "edges": [{"source": pdf, "relation": "FIXED_IN", "target": r311}], }) print(retracted["commit_seq"]) ``` | Method | What it does | | --- | --- | | `graph(name).retract(body)` or `retract(body)` | Removes links, and optionally fields, as a new commit. | | `reload(lines, confirm=, dry_run=)` | Makes the graph match the NDJSON lines in one commit. Records missing from the lines leave current state. `confirm` is the graph name. `dry_run=True` previews the change and writes nothing. | | `fork_graph(src, dst)` | Copies a graph into a new graph as a background job. The destination must be a new graph id. The id of a deleted graph is refused. Safe to retry. | | `create_graph()` | Creates the client's graph with an empty schema. `ontology.define` also creates it. | | `list_graphs()` | Lists the graphs of the environment. | | `graph(name).delete(confirm=)` or `delete_graph(confirm=)` | Deletes the graph, its search feedback and its pending jobs. `confirm` is the graph name. Safe to retry. | A `reload` response carries `prior_commit_seq`. Read the graph at that commit with `as_of_commit_seq` to see the state before the reload. ```python planned = lbb.reload(tickets, confirm="support", dry_run=True) print(planned.prior_commit_seq) ``` ### Publication status After a commit, the platform publishes it for search and eventual reads. | Method | Returns | | --- | --- | | `wait_for_published(commit_seq, timeout=30.0, poll_interval=0.25)` | Returns when the latest published commit covers `commit_seq`. Raises `TimeoutError` after `timeout` seconds, and `RuntimeError` when publication is `blocked`. Waits longer when the server sends a longer retry delay. | | `publication_status()` | `state` (`current`, `queued`, `compacting`, `building`, `publishing` or `blocked`), `head_seq`, `published_seq`, `lag_commits` and `retry`. | | `read_snapshot()` | `head_seq`, `snapshot.served_at_seq`, `query_lag_commits`, `base_current` and `conformance_lag_commits`. | | `summary()` | Record, link and type counts, with the commit they describe. | | `schema_summary()` | The types and fields found in the published data, with counts. | | `planner_stats(cursor=None, limit=None)` | The statistics the query planner uses: triple counts per link and field, and the indexes present. See [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). | | `metadata()` | The ontology version and storage size of the graph. | `base_current` is `True` when the published data reflects every commit and schema change. ```python published = lbb.read_snapshot_model() print(published.snapshot.served_at_seq, published.query_lag_commits, published.base_current) ``` ### Errors and retries Methods return parsed JSON. On a non-2xx response, they raise `LbbError`. ```python try: lbb.graph("support").facts.create({ "triplets": [{"source": login, "relation": "BLOCKS", "target": auth}], }) except LbbError as err: print(err.status_code, err.code, err) ``` | Attribute | Meaning | | --- | --- | | `status_code` | The HTTP status. | | `code`, `type`, `param` | The error from the response body. `str(err)` is its message. | | `retryable`, `retry_after_seconds` | Whether a retry can succeed, and when. | | `request_id`, `doc_url` | The request id to quote in support, and a link to the docs. | | `endpoint_hint` | For `stack_endpoint_required` and `stack_endpoint_mismatch`: what to fix. | | `details` | The reasons for each item of a refusal. An example is the `conflicts` of `409 starter_conflict`. | | `body`, `error` | The raw response body, and its parsed `error` object. | The client retries these requests: - GET, HEAD and OPTIONS requests. - Writes that carry an idempotency key: commits, imports, retracts, reloads, SPARQL updates, schema publications and draft promotions. The client adds the key when you omit it. - The read-only POST methods `query.structured`, `ontology.search`, `ontology.resolve` and `ontology.draft_validate`. - Graph deletes and forks. - `embeddings.search_tuning.apply`, which sets the same settings again. - A retryable `429` on `sparql` and `query.sparql`. A `5xx` or a transport failure is not retried, because the query could run twice. Other POST and PUT calls are sent once. Examples are `ontology.define`, `embeddings.declare`, `embeddings.search` and `sparql_select`. A retry follows a `429`, a `5xx` or a transport failure. The delay is the `Retry-After` header, the body's `retry_after_seconds`, or a full-jitter exponential backoff. An error that the body marks `retryable: false` is raised at once. `retry_budget_ms` bounds the total retry time, and `max_retries` caps the count. `on_retry` receives a `RetryEvent` with `method`, `path`, `attempt`, `status_code`, `error_code`, `delay_seconds` and `elapsed_ms`. `raw_request(method, path, params=, body=, options=)` returns a `RawLbbResponse`. It has `data`, `status_code`, `request_id`, `version`, `headers`, `attempts`, `retry_count` and `elapsed_ms`. `options` overrides `max_retries`, `retry`, `retry_budget_ms`, `timeout` and `headers` for one call. `retry` is `True`, `False` or `"rate_limited"`, which retries a retryable `429` only. The typed `ontology` and `query` methods take the same `options=` argument. ```python response = lbb.raw_request("GET", "/v1/graph/summary", options={"max_retries": 2}) print(response.status_code, response.request_id, response.attempts) ``` ### Async client `AsyncLbbClient` has the same constructor and the same methods as coroutines. Use it as an async context manager, or call `await lbb.aclose()`. `submit_import_ndjson` also accepts an async iterable. ```python import asyncio from lbb import AsyncLbbClient async def main(): async with AsyncLbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") as client: found, results = await asyncio.gather( client.embeddings.search("export is slow", top_k=2), client.sparql("SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }"), ) print([hit["label"] for hit in found["hits"]], results.rows()[0]["n"]) asyncio.run(main()) ``` ### Typed models The plain methods return `dict`s, which suit scripts and notebooks. For application code, the generated Pydantic models in `lbb.models` give checked fields and editor completion. Some methods return a model by default. They are the `ontology` namespace, `query.structured`, the import jobs, `reload`, `create_graph`, `fork_graph`, `delete_graph` and `wait_for_published`. Other methods have a `*_model` twin: | Returns a `dict` | Returns a model | | --- | --- | | `commit`, `commit_dry_run` | `commit_model`, `commit_dry_run_model` | | `graph(name).facts.create`, `graph(name).retract` | `graph(name).facts.create_model`, `graph(name).retract_model` | | `publication_status`, `graph(name).publication_status` | `publication_status_model`, `graph(name).publication_status_model` | | `summary`, `metadata`, `read_snapshot`, `schema_summary`, `planner_stats`, `list_graphs` | `summary_model`, `metadata_model`, `read_snapshot_model`, `schema_summary_model`, `planner_stats_model`, `list_graphs_model` | | `ontology_view`, `ontology_conformance` | `ontology_view_model`, `ontology_conformance_model` | | `sparql_select`, `entities.filter_by_attributes` | `sparql_select_model`, `entities.filter_by_attributes_model` | | `entities.detail` | `entities.detail_model` | | `schema.view`, `schema.publish` | `schema.view_model`, `schema.publish_model` | Each typed method is annotated with its exact model. `raw.model(Model)` validates any `raw_request` response. The async client has the same helpers as coroutines. Some models hold a commit number as a `CommitSeq`. Read the integer with `.root`, as in `status.committed_commit_seq.root`. ```python from lbb.models import GraphSummaryResponse summary: GraphSummaryResponse = lbb.summary_model() print(summary.entity_count, summary.ontology_version) raw = lbb.raw_request("GET", "/v1/graph/summary") print(raw.model(GraphSummaryResponse).entity_count) ``` A model also works as a request body. The client leaves out each optional field that is `None`. A required field that is `None` is sent as JSON `null`. ```python from lbb.models import OntologyEvolveRequest, WidenRelationOp lbb.ontology.evolve( OntologyEvolveRequest( ops=[ WidenRelationOp( op="widen_relation", relation="FIXED_IN", add_domain=["Ticket"] ) ] ) ) ``` ### Method map | Area | Methods | | --- | --- | | Schema | `ontology.define`, `ontology.evolve`, `ontology.view`, `ontology.conformance`, `ontology.search`, `ontology.resolve`, `ontology.draft_*`, `schema.view`, `schema.publish`; `graph(name).ontology` scopes each one to a graph | | Ontology starters | `ontology.starters.list`, `ontology.starters.get`, `ontology.starters.apply`, `ontology.starters.update`; the module `lbb.starters`: `crm`, `documents`, `work` | | Starter workflows | `workflow_starters`, `use_workflow_starter`, `preview_triggered_workflow`; `graph(name)` scopes each one to a graph; `entity_ref` and `entity_id_from_iri` | | Workflows by name | `workflows.named.list`, `workflows.named.all`, `workflows.named.get`, `workflows.named.put`, `workflows.named.run`, `workflows.named.pause`, `workflows.named.delete`, `workflows.named.queue`, `workflows.named.runs`, `workflows.named.all_runs`, `workflows.named.get_run`, `workflows.named.run_stats`, `workflows.named.run_results`, `workflows.named.all_run_results`, `workflows.named.documents`, `workflows.named.all_documents` | | Files | `files.upload`, `files.list`, `files.get`, `files.counts`, `files.content`, `files.download`, `files.head`, `files.pages`, `files.delete`; `graph(name).files` scopes each one to a graph | | Suggestions | `ontology.suggestions`, `ontology.suggestion_get`, `ontology.suggestion_create`, `ontology.suggestion_validate`, `ontology.suggestion_accept`, `ontology.suggestion_dismiss`, `ontology.suggestion_supersede`, `ontology.suggestion_comment` | | Write | `graph(name).facts.create`, `commit`, `commit_dry_run`, `graph(name).retract`, `retract`, `reload` | | Import | `graph(name).facts.import_rdf`, `import_rdf`, `import_rdf_many`, `graph(name).facts.import_ndjson`, `import_ndjson`, `submit_import_ndjson`, `get_import_job`, `wait_for_import_job`, `cancel_import_job` | | Search | `embeddings.declare`, `embeddings.get`, `embeddings.list`, `embeddings.preview`, `embeddings.search`, `embeddings.set_model`, `embeddings.refresh`, `embeddings.delete`, `embeddings.search_settings`, `embeddings.set_search_settings`, `managed_models`, `model_activity` | | Search tuning | `embeddings.search_tuning.start`, `embeddings.search_tuning.list`, `embeddings.search_tuning.get`, `embeddings.search_tuning.apply` | | Model checks | `checks.summary`, `checks.list`, `checks.review`, `checks.export`, `checks.calls`, `checks.call`, `checks.check_call` | | Model choice | `models.trials.options`, `models.trials.create`, `models.trials.wait`, `models.trials.list`, `models.trials.get`, `models.trials.call`, `models.trials.stop`, `models.switches.create`, `models.switches.list`, `models.switches.revert` | | Query | `sparql`, `query.sparql`, `sparql_select`, `query.structured`, `query.update`, `query.ask`, `query.ask_stream`, `entities.filter_by_attributes` | | Records | `entities.detail`, `entities.detail_model` | | Graphs | `create_graph`, `list_graphs`, `fork_graph`, `delete_graph`, `graph(name).delete` | | Integrations | `integrations.connectors`, `integrations.provider_apps`, `integrations.create_connect_session`, `integrations.get_connect_session`, `integrations.create`, `integrations.list`, `integrations.get`, `integrations.set_credentials`, `integrations.set_settings`, `integrations.sync`, `integrations.pause`, `integrations.resume`, `integrations.delete`, `integrations.erase`, `integrations.suggestions`, `integrations.accept`, `integrations.dismiss`; `google_drive.authorize_url`, `google_drive.exchange_code` and `google_drive.exchange_code_async` (module `lbb.google_drive`) | | Publication | `wait_for_published`, `publication_status`, `read_snapshot`, `summary`, `schema_summary`, `metadata`, `status` | | Transport | `raw_request`, `idempotency_key`, `close`, `graph` | The client has further methods for managed evals and model training. This page does not cover them. ### Native SPARQL protocol Each environment also serves the SPARQL 1.1 Protocol at `/sparql`. Standard tools such as YASGUI, Protégé and RDFLib's `SPARQLWrapper` connect there. It takes `GET ?query=`, a form POST, or an `application/sparql-query` body. The `Accept` header selects the format. SELECT and ASK return JSON, XML, CSV or TSV. CONSTRUCT and DESCRIBE return Turtle or N-Triples. `/sparql` accepts `consistency`, `min_indexed_seq`, `as_of_commit_seq` and `entailment` as query parameters. To export the graph as RDF, run a CONSTRUCT query there: ```python exported = lbb.raw_request( "GET", "/sparql", params={"query": "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"}, options={"headers": {"accept": "application/n-triples"}}, ) print(len(exported.data.splitlines())) ``` The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/#native-sparql-11-protocol) covers the protocol in full. ### Optional application helpers Start with the client above. The optional `catalog` and `access` helpers add namespace membership, exact-epoch adoption, bounded catalog pages and delegated read tokens. They require separately enabled server support. Shared-reference queries also require explicit access to every selected source. See [How to model your app](https://docs.littlebigbrain.com/start/model-your-app/) for boundaries and an example. ## MCP server for AI agents URL: https://docs.littlebigbrain.com/sdks/mcp/ Source: docs-site/src/content/docs/sdks/mcp.mdx Description: Give an agent tools to search your graph by meaning, follow links with SPARQL, cite the commit it read, and write checked records. Local stdio or hosted OAuth. `@littlebigbrain/mcp` is an [MCP](https://modelcontextprotocol.io) server. It gives an agent a small set of tools over your graph. With them, an agent can: - search by meaning, - follow links with SPARQL, - cite the commit it read, - write records that your schema checks. A typical agent turn has three calls. Search finds a starting record. A SPARQL query follows its links to the answer. The answer cites the rows and the commit. The [quickstart](https://docs.littlebigbrain.com/start/quickstart/) shows these steps in code. [Your first search through MCP](#your-first-search-and-query-through-mcp) shows them as tool calls. Run the server locally over stdio with `npx @littlebigbrain/mcp`. Or connect to the hosted endpoint with OAuth sign-in. The server wraps the [`@littlebigbrain/client`](https://docs.littlebigbrain.com/sdks/typescript/) SDK. This page describes version 0.16.0, which uses client 0.31.0. Source and issues: [github.com/littlebigbrains/lbb-mcp](https://github.com/littlebigbrains/lbb-mcp) (Apache-2.0). ### Discover the hosted MCP server The [agent skills index](https://littlebigbrain.com/.well-known/agent-skills/index.json) provides focused instructions for connecting to MCP and querying a graph with its real vocabulary and commit evidence. It follows Agent Skills Discovery v0.2.0; each entry points to a Markdown skill and includes a SHA-256 digest. Verify the downloaded file against that digest before loading its instructions. The [AI catalog](https://littlebigbrain.com/.well-known/ai-catalog.json) lists the MCP server, both OpenAPI schemas and two agent skills, with stable identifiers and example queries for semantic discovery. The same manifest is available at the current [ARD discovery path](https://littlebigbrain.com/.well-known/ard.json). Follow its [MCP server card](https://littlebigbrain.com/mcp-server-card.json) to connect. The card describes the hosted Streamable HTTP endpoint and requires your existing stack slug in `X-LBB-Stack`. Follow the [OAuth protected-resource metadata](https://mcp.littlebigbrain.com/.well-known/oauth-protected-resource) for sign-in, then connect to `https://mcp.littlebigbrain.com` with that header. You can also use the stack-specific URL described below. The current [SEP-2127 format](https://github.com/modelcontextprotocol/experimental-ext-server-card) describes identity and connection details. The server reports capabilities and the available tools when your MCP client connects. A [compatibility card](https://littlebigbrain.com/.well-known/mcp/server-card.json) also provides the earlier `serverInfo` and `capabilities` fields for clients that use the original well-known discovery path. ### Browser tools on the marketing site The [marketing site](https://littlebigbrain.com/) also exposes browser tools through [WebMCP](https://webmachinelearning.github.io/webmcp/) where the browser supports it. Agents can find and open public pages, read the current page and estimate Cloud or Pro pricing. The contact form lets an agent prepare an enquiry for the user to review and send. These browser tools operate on the marketing site; graph queries use the authenticated MCP server described here. ### Discover the service documentation through DNS Query the SVCB record at `_index._agents.littlebigbrain.com` to find the public service documentation index. It advertises `docs.littlebigbrain.com`, HTTPS on port 443 and the path `/llms.txt` through the experimental DNS-AID `well-known` parameter, encoded as `key65409` pending registration: ```text _index._agents.littlebigbrain.com. 3600 IN SVCB 1 docs.littlebigbrain.com. ( mandatory=alpn,port,key65409 alpn="h2,http/1.1" port=443 key65409="/llms.txt" ) ``` Fetch [the index](https://docs.littlebigbrain.com/llms.txt) to find the MCP, HTTP API and authentication documentation. A DNS-AID client needs to understand the mandatory path parameter to use this record. The hosted MCP connection still uses your stack's URL and OAuth sign-in, as described below. ### Task-shaped tools The set has one tool per task. It does not mirror the HTTP routes. | Tool | Purpose | | --- | --- | | `lbb_inspect` | graph context and exact reads: guide, graphs, paginated ontology/schema, publication status, conformance, metadata, entity, ontology change suggestions | | `lbb_rdf` | import full RDF/OWL or add axioms using INSERT DATA (an update that removes asks the person to approve) | | `lbb_query` | analytical reads: structured query, SPARQL text, search by meaning, questions in plain words, canned analysis, and four tools for writing queries (names, describe, commit_at, compare) | | `lbb_embeddings` | search setup, read only: list and inspect embeddings, preview the text and cost of embedding a class | | `lbb_embeddings_manage` | declare or refresh an embedding (spends model tokens) | | `lbb_embeddings_delete` | delete an embedding and its vectors (asks the person to approve) | | `lbb_fit_sources` | fit from text, read only: list the fit sources, preview the text the fit reads | | `lbb_fit_sources_manage` | declare, dry-run or refresh a fit source (spends model tokens) | | `lbb_fit_sources_delete` | delete a fit source (asks the person to approve) | | `lbb_workflows` | starter workflows, read only: the starter workflows this server offers, the graph's triggered workflows with their status, the preview of creating one, the workflows with triggers, what a watch has not sent yet, the history of a workflow's settings, workflow tests, and what the runs did with each input | | `lbb_workflows_manage` | create a workflow from a starter workflow on a class or change one, ask it to run now, pause and resume, define a workflow with triggers of your worker, use earlier settings of a workflow again, or start, answer and cancel a workflow test | | `lbb_workflows_delete` | delete a triggered workflow or a workflow with triggers (asks the person to approve) | | `lbb_reviews` | review items, read only: the questions for a person, one page at a time, one item, and the items grouped by pattern | | `lbb_reviews_manage` | file a review item, answer one or a batch, comment, or withdraw an item (an answer that commits asks the person to approve) | | `lbb_files` | files, read only: the graph's files with their parse status, filtered by status or name, and the parsed lines of a page range, each with its box on the page | | `lbb_commit` | preview/write statements or entity properties, and retract them (a retract asks the person to approve) | | `lbb_model_choice` | the model each use runs, trials of other models with their reports, and the model choices of the workflow steps | | `lbb_model_choice_manage` | start or stop a trial, switch a use to a tested model, revert (a trial spends model and judge tokens); set or remove the model of a workflow step | | `lbb_models` | evaluation and training inputs, and the managed models' activity | | `lbb_evals` | label results as relevant or not, save evaluation queries, and check them at the current commit; read the model checks and correct one; keep datasets of examples | | `lbb_evals_delete` | delete a dataset of examples (asks the person to approve) | | `lbb_configure` | preview/define/evolve native metadata, list and apply ontology starters, suggest an ontology change, activate SHACL shapes, or keep the graph's notes for questions in plain words (an evolve that removes and a `reject`-mode publish ask the person to approve) | Call `lbb_inspect` with `action: "ontology"` before the agent writes a query or a statement. The agent then uses only the types, relations and properties of the pinned commit. Read tools accept `detail: "compact" | "standard" | "full"`. The default is `compact`, which returns this envelope: `{summary, data, counts?, truncated?, rows_shown?, next?}`. ### Local (stdio): Claude Code, Cursor, Codex The client starts the server as a local process. They talk over standard input and output (stdio). This needs Node.js 18 or later. Copy the complete endpoint and a stack API key from **Connect** in the [console](https://cloud.littlebigbrain.com). Add them to your client's MCP configuration, such as `.mcp.json` or `mcp.json`: ```json { "mcpServers": { "lbb": { "command": "npx", "args": ["-y", "@littlebigbrain/mcp"], "env": { "LBB_BASE_URL": "https://--.db.eu.littlebigbrain.com", "LBB_API_KEY": "" } } } } ``` | Variable | Meaning | | --- | --- | | `LBB_BASE_URL` | Required. The complete stack endpoint from **Connect**. Without it, the server starts and lists its tools, and each tool call fails with a message that asks for it. | | `LBB_API_KEY` | The stack API key. Keep it in your local configuration. | | `LBB_GRAPH` | Optional. The graph for calls that pass no `graph`. The default is `main`. | | `LBB_MCP_DESTRUCTIVE` | Optional. `allow` runs a call that deletes or removes data without a question when the client cannot show one. Leave it unset to keep the question. See [Approve destructive writes](#approve-destructive-writes). | ### Hosted (streamable-HTTP, OAuth) The hosted endpoint is `https://mcp.littlebigbrain.com`. It uses the MCP OAuth sign-in. You sign in to your little big brain account. No stack API key is stored on your machine. Claude-style clients use the URL of one stack: ```json { "mcpServers": { "lbb": { "url": "https://mcp.littlebigbrain.com/mcp/" } } } ``` A client that supports remote MCP auth runs the sign-in by itself: 1. It receives an authorization challenge from the endpoint. 2. It opens the browser sign-in. 3. It sends the resulting access token with each call. The endpoint checks the token and confirms that your account owns the stack. It then creates a short-lived session and runs the tools on that stack. Add `?graph=` to the URL to change the default graph from `main`. | Response | Cause | | --- | --- | | `401` with an authorization challenge | The access token is missing or not valid. The client signs in again. | | `402 subscription_required` | The account's subscription setup is not complete. Finish it in the console. | | `403 insufficient_scope` | A claimed agent token lacks the trusted `mcp:access` permission. | | `404` `unknown stack` | Your account does not own a stack with that slug. | | `405` | The request was not a `POST`. | | `503` | The stack's endpoint is not ready yet. Retry from the console. | For agent identity registration, follow [auth.md](https://littlebigbrain.com/auth.md). The guide uses WorkOS's generated instructions once the issuer enables Agent Registration, and the existing human OAuth flow until then. A claimed agent's access token must carry `mcp:access`, granting read and write access to its organization's stacks. Each request checks revocation, the completed service-auth claim and the delegated user's active membership before opening a database session. See [agent authentication](https://docs.littlebigbrain.com/start/authentication/#authmd-agent-registration). > [!CAUTION] Codex Desktop Codex sends the MCP URL as an OAuth `resource` parameter. It also appends any configured `oauth_resource`. The authorization server then receives two `resource` values and rejects the request. For Codex, use the origin as the URL. Pass the stack in the `X-LBB-Stack` header, and leave `oauth_resource` unset: ```json { "mcpServers": { "lbb": { "type": "http", "url": "https://mcp.littlebigbrain.com", "headers": { "X-LBB-Stack": "" } } } } ``` You can also serve the tools over streamable HTTP with a stack API key. Run the HTTP server from your own Node process, as in [Embed in a Node process](#embed-in-a-node-process). ### Your first search and query through MCP Run the [quickstart](https://docs.littlebigbrain.com/start/quickstart/) first. It creates the `support` graph with tickets, bugs, releases and a ticket embedding. Then connect one of the transports above to the same stack. Ask your agent: **which release fixed the login bug?** The agent reads the schema first, with `lbb_inspect`: ```json { "action": "ontology", "graph": "support" } ``` It lists the types `Ticket`, `Bug` and `Release` and the links `REPORTED_AS` and `FIXED_IN`. Next, the agent searches by meaning with `lbb_query`: ```json { "mode": "search", "graph": "support", "text": "users cannot sign in after the upgrade", "top_k": 3 } ``` The top hit is the ticket `login fails after 3.1`. The agent takes its `iri` and the response's `served_at_seq` into the next call. It follows the links with `lbb_query` at that commit: ```json { "mode": "sparql", "graph": "support", "as_of_commit_seq": 1, "query": "PREFIX dr: PREFIX rdfs: SELECT ?bug ?release WHERE { dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . }" } ``` The result has one row: | bug | release | | --- | --- | | session token expiry | Release 3.2 | The answer comes from two stored links. The agent does not infer the connection from similar text. It can cite the rows and commit `1`. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) explains how long a commit stays readable. ### Build a graph through MCP An agent can also build a graph with tools alone. The calls below write the quickstart records into a new graph, `support-agent`. The search and query above then work on it with `"graph": "support-agent"`. Use a new graph for this. `lbb_commit` writes records by type and name, and the quickstart writes them with a `key`. On one graph, that gives two records for each ticket. **1. Preview the schema.** Call `lbb_configure`. With `dry_run: true`, the call shows the result and writes nothing: ```json { "action": "define_ontology", "graph": "support-agent", "dry_run": true, "entity_types": ["Ticket", "Bug", "Release"], "relations": [ { "name": "REPORTED_AS", "source": "Ticket", "target": "Bug" }, { "name": "FIXED_IN", "source": "Bug", "target": "Release" } ] } ``` Send the same call without `dry_run` to create the graph. A repeat of the same call returns `changed: false`. **2. Add a field.** Call `lbb_configure` again: ```json { "action": "evolve_ontology", "graph": "support-agent", "ops": [{ "op": "add_property", "name": "description", "value_type": "text" }] } ``` **3. Write the records.** Call `lbb_commit` with links (`triplets`) and field values (`entity_properties`): ```json { "graph": "support-agent", "triplets": [ { "source": { "type": "Ticket", "name": "login fails after 3.1" }, "relation": "REPORTED_AS", "target": { "type": "Bug", "name": "session token expiry" }, "evidence": "Support triage, 2 Sep" }, { "source": { "type": "Ticket", "name": "invoice PDF is blank" }, "relation": "REPORTED_AS", "target": { "type": "Bug", "name": "PDF fonts missing" }, "evidence": "Support triage, 3 Sep" }, { "source": { "type": "Ticket", "name": "CSV export times out" }, "relation": "REPORTED_AS", "target": { "type": "Bug", "name": "export runs on one thread" }, "evidence": "Support triage, 4 Sep" }, { "source": { "type": "Bug", "name": "session token expiry" }, "relation": "FIXED_IN", "target": { "type": "Release", "name": "Release 3.2" }, "evidence": "Release notes 3.2" }, { "source": { "type": "Bug", "name": "PDF fonts missing" }, "relation": "FIXED_IN", "target": { "type": "Release", "name": "Release 3.1.1" }, "evidence": "Release notes 3.1.1" } ], "entity_properties": [ { "type": "Ticket", "name": "login fails after 3.1", "properties": { "description": "Since 3.1, people are sent back to the login screen right after they type their password." } }, { "type": "Ticket", "name": "invoice PDF is blank", "properties": { "description": "Downloaded invoices open as empty pages." } }, { "type": "Ticket", "name": "CSV export times out", "properties": { "description": "Exports with more than 10,000 rows stop after 30 seconds." } } ] } ``` The response reports `commit_seq` and echoes `written_properties`, the fields that were stored. **4. Wait for publication.** Call `lbb_inspect` until `state` is `current`: ```json { "action": "publication", "graph": "support-agent" } ``` **5. Turn on search.** Call `lbb_embeddings_manage` to declare an embedding on the `Ticket` type: ```json { "action": "declare", "graph": "support-agent", "class": "https://littlebigbrain.com/class/ticket", "from": ["label", "description"] } ``` A declaration spends model tokens. Preview the text first with `lbb_embeddings` and `action: "preview"`. Then call `lbb_embeddings` with `action: "get"` and `name: "ticket"` until the embedding reports `serving`. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) covers each option. #### Schema calls `lbb_configure` has these schema actions. Each one that writes takes `dry_run: true` to preview without writing. | Action | Arguments | Behavior | | --- | --- | --- | | `define_ontology` | `graph` (required), `entity_types` and `relations`, or `source` with `format`; `merge_default` | Creates the graph with its types and links. On an existing graph, it adds what is new. | | `evolve_ontology` | `ops` (required, in order), `graph` | Adds, renames, widens, narrows or removes types, links and fields. | | `list_starters` | `graph` | Lists the [ontology starters](https://docs.littlebigbrain.com/guides/ontology-starters/) (`crm`, `documents`, `work`) with each one's status on the graph. | | `apply_starter` | `starter` (required), `expected_ontology_version`, `graph` | Adds what the graph lacks of a starter in one ontology version. Refused with `starter_conflict` when the graph holds a term differently. | | `suggest_ontology_change` | `title` (required), `change`, `facts`, `rationale`, `anchor`, `key`, `evidence`, `agent`, `graph` | Files the change as a [suggestion](https://docs.littlebigbrain.com/guides/ontology-suggestions/) for a person to review. `change` holds up to 128 `evolve_ontology` operations. `facts` holds graph facts (`triplets`, `entity_properties`, `replace_owned`, `summary`) that accepting commits; `change` may be empty when `facts` is set. Nothing changes until a person accepts it. | | `publish_schema` | `shapes` (required), `desired_mode`, `confirm_restrictive`, `ontology`, `graph` | Activates SHACL rules. In `reject` mode it asks the person to approve first. See [Validate writes](#validate-writes-with-shacl). | | `get_rewrite_profile` | `graph` | Reads the graph's [profile](https://docs.littlebigbrain.com/guides/answer-questions/#notes-for-every-question): the notes and worked examples the model reads for every question in plain words, and its `version`. | | `set_rewrite_profile` | `notes`, `examples` (up to 20 `{question, sparql, note?}`), `expected_version`, `graph` | Stores the profile. Pass the `version` you read as `expected_version`; another write first answers `409 conflict`. | For `define_ontology`, give both `entity_types` and `relations`. Each type is a name or `{ "name": … }`. A type object accepts `name`, `description`, `resolvable` and `super_types`. Another field fails with HTTP 400, which lists the accepted fields. A link object has `name`, `source` and `target`, as in the example. It also accepts `description`, `inverse`, `symmetric`, `transitive`, `reducer`, `cardinality` and `temporal_semantics`. `merge_default: true` adds the built-in AI-context vocabulary under your types. The tool's default is `false`. On an existing graph, `define_ontology` refuses a change that narrows a link's source or target types. The error is `ontology_restrictive_change`. Use `evolve_ontology` with `narrow_relation` for that change. `super_types` and `add_super_types` store each parent as an `rdfs:subClassOf` statement. A query with `entailment: "subclass"` then matches the records of each subtype. See [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/#add-a-type-hierarchy-to-json-records). `define_ontology` also takes a `source` document instead of names. Its `format` is `auto` (the default), `turtle`, `json_ld`, `rdf_xml`, `csv`, `tsv`, `lbb_json` or `spec`. `lbb_json` expects a serialized ontology. `spec` is the JSON form above. From an RDF or OWL document, `define_ontology` keeps only types and links. To store the whole document as queryable facts, use `lbb_rdf`. `evolve_ontology` takes these `op` values: | `op` | Arguments | | --- | --- | | `add_entity_type` | `name` | | `add_super_types` | `entity_type`, `super_types` | | `add_relation` | `name`, `domain`, `range`, `cardinality`, `temporal_semantics`, `reducer`, `inverse_name`, `transitive`, `symmetric` | | `add_property` | `name`, `value_type` (`bool`, `i64`, `f64`, `date_time`, `keyword`, `text` or `bytes`; default `text`), `required`, `unit` (of an `i64` or `f64` field, such as `mm`, `kg` or `h`), `allowed_values` | | `set_property_unit` | `property`, `unit` (leave it out to clear the unit) | | `set_property_constraint` | `property`, `allowed_values` (an empty list clears the constraint) | | `widen_relation` | `relation`, `add_domain`, `add_range` | | `narrow_relation` | `relation`, `remove_domain`, `remove_range` | | `rename_entity_type`, `rename_relation` | `from`, `to` | | `set_relation_inverse` | `relation`, `inverse_name` | | `set_relation_cardinality` | `relation`, `cardinality` | | `remove_entity_type`, `remove_relation` | `name` | `allowed_values` is enforced: a commit with another value for the field fails. It works on `keyword`, `text`, `i64` and `bool` fields. Values stored before a constraint or a unit change stay as they are: a unit change converts nothing, and a constraint checks only later commits. A removed type or link stays readable in old records. New commits that use it fail. Preview a narrowing or removing change with `dry_run: true`. The preview reports conflicts with current data. Repair them, then apply. `allow_data_conflicts` does not skip that check. A call that removes asks the person to approve first. See [Approve destructive writes](#approve-destructive-writes). #### Write calls `lbb_commit` has three modes. It picks `retract` when you pass `retract_edges` or `retract_entities`, and `search_feedback` when you pass `search_feedback`. Otherwise it writes facts. | Argument | Meaning | | --- | --- | | `triplets` | Links. Each has `source` and `target` as `{ "type", "name" }`, a `relation`, and optional `evidence`, `confidence` (0 to 1) and `valid_time` (`start`, `end`, `granularity`, `source_text`). | | `entity_properties` | Field values. Each item is `{ "type", "name", "properties" }`. | | `entity_embeddings` | Vectors you computed yourself. Each item has `type`, `name`, optional `key` or `entity_id`, `model_id`, `vector`, and optional `metric`. | | `dry_run` | Checks a facts commit, returns its SHACL report, and writes nothing. | | `observed_at` | An RFC 3339 time. The commit is recorded as of that time, for backfills. | | `edge_idempotency` | `append` (the MCP default) or `skip_unchanged`. | | `idempotency_key` | Optional. See [Idempotent writes](#idempotent-writes). | | `retract_edges` | Links to remove, each `{ source, relation, target }`. | | `retract_entities` | Records whose current links are all removed. Their history stays readable. | | `search_feedback` | Relevance labels for search results. | Values are converted to each field's declared type. The string `"52"` lands in an `i64` field, and `"2026-06-26"` in a `date_time` field. An undeclared field fails the commit with HTTP 400. Declare the field with `add_property` first. The `source` and `target` of a triplet carry only `type` and `name`. The tool drops a `key` there. `entity_properties` items pass a `key` through. `retract_edges` and `retract_entities` also match records by type and name. A record written with a `key` does not match, and the call returns `no_op: true`. A retract asks the person to approve first. See [Approve destructive writes](#approve-destructive-writes). A graph whose first write is RDF refuses `lbb_commit` with HTTP 400 `rdf_native_graph`. See [Add RDF and OWL](#add-rdf-and-owl). For `search_feedback`, send the `query`, an optional `search_id`, and `labels`. Each label has a `target`, a `grade`, and optional `rank`, `score`, `reason` and `split`. Grades are `3` (good), `1` (partly relevant) and `0` (bad). A target is `{ "kind": "entity", "entity": { "entity_type", "name" } }`. The kinds `assertion`, `observation` and `concept` name other targets. ### Query options `lbb_query` has nine modes. Each read answers from one commit. #### SPARQL text `mode: "sparql"` runs SPARQL 1.1 `SELECT` and `ASK` queries. `CONSTRUCT` and `DESCRIBE` return HTTP 400. Use the [`/sparql` endpoint](https://docs.littlebigbrain.com/sdks/http-api/) for them. | Argument | Meaning | | --- | --- | | `query` | The query text. Required unless `cursor` is set. | | `entailment` | `none` (default), `subclass`, `rdfs` or `owl`. See [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/). | | `consistency` | `eventual` reads the latest published commit. `strong` also applies the newest writes. | | `min_indexed_seq` | Read at this commit or a later one. | | `as_of_commit_seq` | Read the graph as it was at this commit. | | `row_limit` | Rows per page. See [Pages and large results](#pages-and-large-results). | | `cursor` | The `next.cursor` of the previous page. | | `request` | The user's words behind the query. The server records an eval trace, and the result names its `trace_id`. | | `detail`, `graph` | As for every tool. | Without `as_of_commit_seq`, the tool reads the graph's latest commit number first. Every page of that query then reads that commit. A valid-time `as_of` is refused before any HTTP call, also inside an older cursor. Use `as_of_commit_seq` for an earlier commit. A SPARQL query can also search by meaning. The pattern `?x "words"` binds the records nearest to the words, and the other patterns of the query filter and join them. The result then carries `search`: the plan (`nearest`, `filter_first` or `search_first`), the hits asked for and bound, and `complete`. When `complete` is false, `notes` says so. With `search:rerank true` and `search:relevance ?r`, the rerank model keeps the hits that answer the words and binds its answer; `search.rerank` reports how it went. [Search inside a SPARQL query](https://docs.littlebigbrain.com/guides/search-by-meaning/#search-inside-a-sparql-query) lists the patterns. > [!TIP] Lowercase IRIs SPARQL text (`mode: "sparql"`) addresses little big brain relations, types, and properties as ``, always **lowercase**. The tool lowercases those local names for you and reports each rewrite under `notes`. An uppercase name then still matches. IRIs from imported RDF keep their original form. #### Structured JSON `mode: "structured"` takes a JSON `body` instead of query text. A `predicate` is a link name, in any case. This body counts the tickets fixed in each release: ```json { "mode": "structured", "graph": "support", "body": { "patterns": [ { "subject": { "var": "t" }, "predicate": "REPORTED_AS", "object": { "var": "b" } }, { "subject": { "var": "b" }, "predicate": "FIXED_IN", "object": { "var": "r" } } ], "group_by": ["r"], "aggregates": [{ "func": "count", "as": "tickets" }] } } ``` Each release comes back in `groups`, with its record under `keys.r` and the count under `aggregates.tickets`. | Body field | Shape | | --- | --- | | `patterns` | `[{ subject, predicate, object }]`. A term is `{ "var": "x" }` or a fixed `{ "entity": { "entity_type", "name" } }`. | | `filters` | `{ "compare": { "op", "left", "right" } }`, or `and`, `or`, `not` of filters. `op` is `eq`, `ne`, `lt`, `le`, `gt` or `ge`. | | Filter operand | `{ "var" }`, `{ "property": { "var", "field" } }`, or `{ "value": … }` with one of `str`, `i64`, `f64`, `bool`, `date_time`, `entity`. | | `group_by` | Variables to group by record. | | `group_keys` | `{ "property": { var, field, as } }`, or `{ "date_bucket": { var, field, granularity, as } }` with `year`, `month`, `week`, `day` or `hour`. Values come back under `value_keys`. | | `aggregates` | `{ func, as }` with `count`, `sum`, `avg`, `min` or `max`, and optional `operand` and `distinct`. | | `having` | Filters over the groups. | | `order_by` | `[{ var, descending }]` | | `select`, `distinct`, `as_of_commit_seq` | Projection, duplicate removal, and a commit pin. | To count groups without reading them, add an equality `having` and set `row_limit: 1`. Then read `row_page.total`. A body with `combinators` fails: write `OPTIONAL`, `UNION`, `MINUS` or `EXISTS` as SPARQL text. #### Search by meaning `mode: "search"` takes `text`. It searches every searchable type, or one `embedding`. Other arguments are `top_k` (at most 200), `probe`, `include: ["text"]`, `filter`, `explain` and `request`. A `filter` item names a class or a link. A class item is `{ "class": … }`. A link item is `{ "via": …, "to": … }`. Its `direction` is `out` (the default) or `in`. With `explain: true`, the call returns the plan without calling the model. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) explains filters and planning. #### Questions in plain words `mode: "question"` takes a `question` in plain words and calls `POST /v1/query/ask`. The server describes the graph and picks the kind of question. Its model runs queries in a short loop, reads the rows, and answers. The result has the answer with the records it cites, the steps, the route, the query whose rows hold the answer, the rows and the eval `trace_id`. It takes about 6 to 12 seconds. | Argument | Meaning | | --- | --- | | `question` | The question, 1 to 4,000 characters. Required. | | `context` | Notes on what the data means: units, names to prefer. Send the same text with each question. | | `route` | Fixes the kind of question: `lookup`, `aggregate`, `search`, `history`, `schema` or `unanswerable`. | | `limit` | The rows each query returns, 1 to 1,000. | | `anchor` | The IRIs of records the question is about, at most 10. The queries use them directly. | | `timeline` | Dates that stand for the graph's commits, at most 200: `[{date, as_of_commit_seq, label?}]`. A question about a date reads the commit of the latest point on or before it. | | `detail`, `graph` | As for every tool. | The result lists in `linked` the names of the question that the server found in the graph, and in `anchors` what it read about each IRI of `anchor`. A history question returns `history`: the commit the server read for its date, and for an answer from a comparison the rows `added`, `removed` and `changed` with `totals`. Each list shows at most 20, 100 or 500 entries by `detail`; `history.shown` and a note say when a list is cut. The rows are those of the query the answer stands on. The `answer` and `run` arguments of earlier versions are gone: the mode always answers. When the client asks for progress (a `progressToken` in the call's `_meta`), the tool sends each step as a progress notification: the route, each tool call of the loop, and the answer. The result stays the same. Cancelling the call stops the server's work. Use this mode when the agent has not read the schema. When it has, a query it writes itself in `mode: "sparql"` needs no model call. An `unanswerable` route means the graph does not hold the data. [Answer questions in plain words](https://docs.littlebigbrain.com/guides/answer-questions/) covers the routes and the limits. It also lists the data that goes to the model providers. #### Tools for writing queries Four modes give an agent that writes its own SPARQL what only the server knows. None calls a model. | Mode | Arguments | Use it when | | --- | --- | --- | | `names` | `text`, `limit` (1 to 10) | The question names a record. The result groups the candidates by name, the one to prefer first; put its IRI in the query. | | `describe` | `question`, or `classes` and `properties` as IRIs | The agent needs classes or properties it has not seen. The result is the server's text: how many sampled instances hold each property, and the values of small classes. `detail: "full"` adds the JSON. | | `commit_at` | `date` (`YYYY-MM-DD`) or `moment` (RFC 3339) | The question names a date. Run the query with `mode: "sparql"` and the commit it returns as `as_of_commit_seq`. | | `compare` | `query`, `before`, `after`, `key`, `max_rows`, `limit` | The question asks what changed. `before` and `after` are each `{as_of_commit_seq}`, `{date}` or `{moment}`. The result has `added`, `removed` and `changed` as lexical rows, with `totals`; pass `next` back for the next page. | #### Summary charts `mode: "analyze"` returns counts with a Vega-Lite chart. Set `metric` to `entity_types`, `relations`, `overview`, or `sparql` with a structured body in `sparql`. `chart` is `bar` (default) or `pie`, and `top_k` keeps the largest values. ### Pages and large results A query page holds at most `row_limit` rows. The default depends on `detail`: 20 for `compact`, 100 for `standard`, 1,000 for `full`. The maximum is 5,000. Each page reports `row_page: {returned, total, offset, limit, has_more, next_offset?}`. Its `summary` says `returned X of Y rows`. When more rows exist, `next` holds the arguments for the next page: ```json { "mode": "sparql", "cursor": "eyJhc19vZl9jb21taXRfc2Vx…", "row_limit": 2, "detail": "compact" } ``` Pass `next` back to `lbb_query` until it is absent. You do not write `LIMIT` and `OFFSET`. The cursor holds the query, the graph and the commit of page 1. New writes between pages do not change the rows of later pages. A cursor must match the arguments sent with it. A different `query`, `graph`, `row_limit`, `entailment`, `consistency` or `min_indexed_seq` fails. A page keeps whole values within 80,000 bytes of UTF-8. So a page can be shorter than `row_limit`. Follow `next` even then. A cursor moves on only by the rows you received. If one row alone is too large, project fewer fields. Or read that row through the [SPARQL HTTP endpoint](https://docs.littlebigbrain.com/sdks/http-api/). Other read tools shorten large values by `detail`. `compact` keeps 5 items per list and 300 characters per string. `standard` keeps 20 and 1,000. `full` keeps 100 and 5,000. A shortened result sets `truncated` and names the next `detail` level in `next`. A list page (`data` with `has_more` and `next_cursor`) asks the server for at most the items its `detail` level keeps, so `detail` never shortens the page. A page over 80,000 characters is asked for again with fewer items. Its `next_cursor` then continues after the last item shown. `next_cursor` is never shortened. ### Read the schema and records `lbb_inspect` reads graph context and exact records: | Action | Returns | | --- | --- | | `guide` | Counts, types, links and suggested next calls. For a missing graph, the list of graphs and how to create one. | | `graphs` | The graphs of the stack. Works before any graph exists. | | `publication` | Whether writes are published: `state`, `head_seq`, `published_seq`, `lag_commits`. | | `ontology` | Types, links and fields, with counts, in complete pages. | | `schema` | The active schema and SHACL metadata, in complete pages. It runs no validation. | | `ontology_conformance` | The published SHACL report: `conforms`, `result_count`, `validated_at_seq`. | | `ontology_search` | Types, terms and links that match `query`, up to `top_k`. | | `metadata` | Graph status, including the latest commit. | | `entity` | One record by `entity_id`, or by `entity_type` and `name`: its fields and its links. Accepts `as_of_commit_seq`. A valid-time `as_of` is refused before any HTTP call. | | `ontology_suggestions` | The [ontology change suggestions](https://docs.littlebigbrain.com/guides/ontology-suggestions/) and their state, with `identity_count` and `fact_count` (the links and field rows accepting commits). `status` keeps one state: `open`, `accepted`, `dismissed` or `superseded`. `limit` is 50 by default. | `ontology` and `schema` return complete entries. `page_size` sets entries per page: 50 by default, at most 500. `section` limits the pages to one list, such as `entity_type_defs`, `relation_defs`, `property_defs`, `classes` or `relations`. Pass the returned `next` arguments until they are absent. Nested values are never shortened, also with `detail: "compact"`. A single entry can exceed the output budget. The result then carries `entry_fragment`. Join its `serialized_json` parts in `char_offset` order, then parse the JSON. A schema change between pages fails the cursor, so pages never mix versions. Start again without `cursor` after a change. ### Add RDF and OWL `lbb_rdf` stores RDF statements as graph facts. It has two actions: | Action | Arguments | Behavior | | --- | --- | --- | | `import` | `source` (required), `format` (`turtle` default, `ntriples`, `nquads`, `trig`), `base_iri`, `blank_node_scope`, `idempotency_key`, `graph` | Stores a complete document: lists, labels, comments and OWL statements. | | `update` | `update` (required), `idempotency_key`, `graph` | Runs SPARQL Update `INSERT DATA`. | `import` accepts the default RDF graph only. An N-Quads or TriG document must contain only default-graph statements. `update` accepts only `INSERT DATA`. `DELETE`, `WHERE`, `CLEAR` and the other forms return HTTP 400 and change nothing. Named graphs are refused too. A graph whose **first** write is RDF is RDF-native. It refuses later `lbb_commit` writes with `rdf_native_graph`. Decide the write path before the first write. A graph first written with `lbb_commit` accepts `lbb_rdf` as well. This call adds a type hierarchy to the `support` graph. It states that every bug is an issue: ```json { "action": "update", "graph": "support", "update": "INSERT DATA { }" } ``` The response has `accepted: true`, which means the statement is stored. Queries use it once its commit is published. Wait for publication with `lbb_inspect` `action: "publication"`. Then check the answer with `entailment`: ```json { "mode": "sparql", "graph": "support", "entailment": "subclass", "query": "PREFIX rdfs: SELECT ?issue WHERE { ?b a ; rdfs:label ?issue } ORDER BY ?issue" } ``` The query returns the three bugs. Without `entailment`, it returns no rows. [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/#supported-owl-statements) lists the OWL statements that `entailment: "owl"` applies. To read the stored statements, query with `entailment: "none"` and follow each `next` cursor: ```sparql SELECT ?s ?p ?o WHERE { ?s ?p ?o } ORDER BY ?s ?p ?o ``` RDF statements cannot be removed or replaced. An import of a changed document adds its new statements and keeps the old ones. To revise a document, import it into a new graph and check it. Then move your clients to that graph. Keep the original graph until you choose to delete it. A retry with the same content writes nothing new. To write the same content again on purpose, pass a new `idempotency_key`. ### Validate writes with SHACL SHACL is the W3C standard for validation rules. `lbb_configure` with `action: "publish_schema"` activates a set of rules: ```json { "action": "publish_schema", "graph": "support", "dry_run": true, "desired_mode": "warn", "shapes": { "format": "turtle", "source": "@prefix sh: . @prefix dclass: . @prefix dr: . a sh:NodeShape ; sh:targetClass dclass:ticket ; sh:property [ sh:path dr:reported_as ; sh:maxCount 1 ; sh:message \"A ticket is reported as at most one bug.\" ] ." } } ``` - `shapes.format` is `auto`, `turtle`, `n_triples`, `n_quads` or `trig`. - `desired_mode` is `off`, `warn` or `reject`. - `confirm_restrictive` is accepted. The server does not read it. - `ontology` must match the current schema. Change the schema with `define_ontology` or `evolve_ontology`. The preview parses the rules and checks them against the schema. It writes nothing and starts no validation job. It does not check your data. Tighten rules in this order: 1. Publish in `warn` mode. 2. Read the report with `lbb_inspect` `action: "ontology_conformance"`. 3. Repair the records it lists. 4. Publish the same rules in `reject` mode. In `reject` mode, a write that breaks a rule fails, and the error lists the violations. A publish in `reject` mode asks the person to approve first, with the target classes of the rules. See [Approve destructive writes](#approve-destructive-writes). `lbb_commit` with `dry_run: true` returns the same report without writing. See [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). ### Errors A failed call sets `isError`. Its `structuredContent.error` keeps every field of the server error: ```json { "error": { "type": "not_found_error", "code": "graph_not_found", "message": "graph 'nope' does not exist for this tenant", "param": "graph", "request_id": "8661a030-…", "doc_url": "https://docs.littlebigbrain.com/sdks/errors/#graph_not_found", "status": 404 } } ``` An error the tool finds before any HTTP call has `type` and `code` `tool_error`. Examples are an unknown argument and a cursor that does not match its query. A graph error can name only a storage key. The tool then rewrites the message. The new message lists the stack's graphs and the `graph` value to pass. ### Idempotent writes Without an `idempotency_key`, `lbb_commit` and `lbb_rdf` derive one from the graph and the content. A retry with the same content writes nothing new. `lbb_commit` then returns the first result with `idempotent_replay: true`. To repeat an identical write on purpose, pass a new `idempotency_key`. `lbb_commit` sets `edge_idempotency` to `append` by default. New evidence on an existing link is then recorded. Pass `skip_unchanged` for backfills that you run again. It skips exact copies of current links, and repeats that differ only in evidence. ### Approve destructive writes A call that deletes or removes data asks the person first. The server sends the question through the client with MCP form elicitation. The question names what the call removes first, with the counts, for example `Delete the embedding "ticket" of graph "support" and its vectors?`. It has one confirm box. - When the person approves, the call runs. - When the person declines or cancels, nothing is sent. The tool returns an error with the code `approval_declined`. - When the approval request fails, nothing is sent either. The code is `approval_failed`. These calls ask: | Call | What the question names | | --- | --- | | `lbb_embeddings_delete` | the embedding and its graph | | `lbb_fit_sources_delete` | the fit source and its graph | | `lbb_workflows_delete` | the workflow, its starter workflow and its graph | | `lbb_evals_delete` | the dataset, its id, its item count and its graph | | `lbb_commit` with `mode: "retract"` | the counts, then each link and record it removes (the first 20 of each, then a count) | | `lbb_rdf` with an `update` that holds `DELETE`, `CLEAR`, `DROP`, `MOVE` or `COPY` | each operation that removes, quoted on lines of its own, then the forms of the other operations | | `lbb_configure` with `action: "evolve_ontology"` and `remove_entity_type`, `remove_relation`, or `narrow_relation` with `remove_domain` or `remove_range` | the counts, then each type and link it removes and each type it takes out of a link's source or target types (the first 20, then a count) | | `lbb_configure` with `action: "publish_schema"` and `desired_mode: "reject"` | the target classes of the rules and the size of the source | | `lbb_reviews_manage` `answer` or `answer_batch` that accepts or corrects a `value` item, or accepts a `change` item | the count and the effect: how many items commit a value and how many apply an ontology change, then each item with its value, or with what its change removes | The question escapes newlines and other control characters in names and values, so a name cannot add lines of its own. `lbb_model_choice_manage` `put_choice` and `delete_choice` ask nothing. They change only the model of a workflow step's future calls, remove no data, and a choice is set again in one step. Some calls read first, so the question can name what they change. `lbb_evals_delete` reads the dataset. When it does not exist, that read fails with 404, and nobody is asked. A review answer reads its items, and the suggestion of each `change` item. An answer that commits nothing asks nothing: a `reject`, a `cant_tell` or a `choose`, and an answer to an item that is answered, withdrawn or in `test` scope. The answer to an item that Evals filed (a check, a calibration question, a golden) is a grade or a choice, so it asks nothing either. A preview (`dry_run: true`) of an `evolve_ontology` or a `publish_schema` writes nothing, so it does not ask. Some clients cannot show the question: a client without elicitation, or with URL elicitation only. The HTTP transport of this server, which the hosted endpoint uses, handles each call as a request of its own, so it cannot ask either. Then the call fails with `approval_required`, and nothing is sent. No tool argument changes that, since the agent sets the arguments. The person who runs the MCP server can set `LBB_MCP_DESTRUCTIVE=allow` in its environment. The server then runs these calls without a question when the client cannot show one. The hosted endpoint does not set it. There, every call that deletes or removes data answers `approval_required`. Make that change in the console or with the TypeScript or Python SDK instead. The server writes one JSON line per tool call to standard error: ```json {"event":"mcp_tool_call","tool":"lbb_embeddings_delete","outcome":"error","code":"approval_declined","approval":"declined","request_id":7} ``` `action` names the call's action, `approval` what the approval step decided (`approved`, `declined`, `cancelled`, `failed`, `unavailable` or `allowed_by_setting`), and `request_id` the JSON-RPC request. A line holds no arguments, names or texts. ### Other tools | Tool | Actions | | --- | --- | | `lbb_embeddings` | `list`: each embedding's status, progress and lag, and the graph's model. `get`: one embedding by `name`. `preview`: the text of sample records and the facts of a class, with no model call. | | `lbb_embeddings_manage` | `declare`: a new embedding or a changed recipe (`class`, `from`, `exclude`, `title`, `name`). `refresh`: one embed step now, by `name`. `model`: move every embedding of the graph to `model` (and `dim`). | | `lbb_embeddings_delete` | Deletes the embedding `name` and its vectors, once the person approves. | | `lbb_fit_sources` | `list`: each fit source's status, the suggestions it filed and this month's spend. `get`: one by `name`. `preview`: the text the fit reads for a few records of a class, with no model call. | | `lbb_fit_sources_manage` | `declare`: a fit source on a class (`class`, `from`, `exclude`, `context`, `name`); the server reads every record and files ontology suggestions. `dry_run`: the proposals for up to 3 records, nothing filed (spends model tokens). `refresh`: ask the job to run now, by `name` (returns at once). | | `lbb_fit_sources_delete` | Deletes the fit source `name`, once the person approves; its suggestions stay. | | `lbb_workflows` | `starters`: the starter workflows this server offers, with their group, their settings and whether each can run here. `list`: the graph's triggered workflows with their state, lag, counters, spend and last error. `get`: one, by `starter` and `name`, with its own status in `details`. `preview`: what creating a workflow would do for `starter`, `class`, `fields`, `exclude` and `params` (and `workflow`, `owns` and `batch` for a developer workflow, whose preview shows its first trigger messages): the resolved watch, the instance count and the starter workflow's own preview. It writes nothing. `workflows`: the workflows with triggers, or one by `name`, with each one's target type, shards, routing field, window, trigger and shard inboxes. `queue`: what a workflow's watch has not sent yet, by `name`, each record with its shard and origin, one page in `data` (`cursor` takes the answer's `next_cursor`; `limit`, `trigger`). `run_results`: what a workflow's runs did with each input, by `name`, newest run first: the input, its outcome and its values with their quotes and review items, one page in `data` (`run` for one run with `run_counts`, `outcome`, `iri`, `cursor`, `limit`). `revisions`: the history of a workflow's settings by `name`, newest change first (`cursor`, `limit` 1 to 100): each item a revision (its `settings`, its id `wrev_…`, the hash of those settings) with who changed it, when, how, and `bound` (the one in use). `revision`: one revision by `name` and `revision`; compare two by their `settings`. `tests`: one page of the graph's [workflow tests](https://docs.littlebigbrain.com/guides/test-a-workflow/), newest first (`name`, `limit`, `cursor`). `tests_get`: one test by `id`, with each input's text and, per value, its quote, its support and what the live workflow does with it. | | `lbb_workflows_manage` | `use`: create a workflow from a starter workflow on a class, or change one (`starter`, `class`, `fields`, `exclude`, `params`, `name`, `gate`, `budget_usd_per_month`); the workflow reads every instance, then follows each published commit. A developer workflow (`starter: "workflow"`) also takes `workflow` (`workflow_type`, `version`), `owns` (`classes`, `relations`, `properties`) and `batch` (1 to 64). `refresh`: ask the workflow to run now, by `starter` and `name` (returns at once). `pause` and `resume`: stop a workflow from reading and sending, or let it catch up. Every workflow pauses but `search.embed` and `ontology.fit`, which answer `workflow_pause_unsupported`. Without `starter` they name a workflow with triggers. `define`: create or change a workflow with triggers of your worker (`name`, `workflow`, one trigger, `owns`, `batch`, `shards`, `route_by`, `window`, `trigger`). The trigger is `class` and `fields` (a watch), `media_types` (a content trigger on uploads), `schedule` (`{cron, timezone}` or `{every, offset}`, and `jitter`) or `stream` (`{max_wait}`: the events a client sends, in batches). `run`: run one now, by `name`. `activate`: make a kept revision the one in use, by `name` and `revision`, with `expected_revision` (the revision you read as bound) so a change by someone else since fails with `workflow_revision_conflict`. An activation changes what the workflow does from then on and deletes no data, so it is not a destructive write and asks for no confirmation. Show the user what changes first. `tests_create`: test a `values.extract` workflow on chosen inputs before it writes. Name a workflow of the graph (`name`), or pass `install`, the `use` request of a workflow you have not created. Add `iris` (at most 20) or `sample` (1 to 20, default 6). `tests_answer`: the person's answer to one value (`id`, `iri`, `property`, `answer`); `answer: null` removes it. `tests_cancel`: stop a test by `id`. A test writes nothing to the graph. | | `lbb_workflows_delete` | Deletes the triggered workflow of `starter` named `name`, or without `starter` the workflow with triggers named `name`, once the person approves; what it wrote stays. | | `lbb_reviews` | `list`: one page of [review items](#review-items), the latest update first, as the server sends it (`object`, `data`, `has_more`, `next_cursor`). Filters: `status`, `kind` (one or a list), `scope`, `origin_id`, `workflow`, `run`, `key`, `eval_id` (the items Evals filed for one record: a calibration session, a model call's check, a golden); page with `limit` and `cursor`. `get`: one item by `id`. `patterns`: the live items grouped by the decision they ask (`status`, `origin_id`, `type`, `eval_id`). | | `lbb_reviews_manage` | `create`: file an item of kind `value`, `choice`, `grade` or `answer` (`kind`, `title`, `question`, and `subject`, `evidence`, `rationale`, `key`, `scope`, `facts`, `origin`). `answer`: answer one item (`id`, `answer`, `revision`). `answer_batch`: one answer for up to 500 `ids`. `comment` (`id`, `text`, `author`). `withdraw` (`id`, `reason`). | | `lbb_files` | `list`: 50 files a call, newest upload first, with their SHA-256, name, size, `Document` IRI and parse status; when more follow, `next` names the `after` of the next call. `parse_status` keeps the files of one status: `parsed`, `waiting`, `unsupported` or `failed`. `name` keeps the files whose name holds a text, in any case. A filtered list reads the published generation and returns its commit (`as_of_commit_seq`). A new upload can be missing from it until publication. Its `next` repeats the filters. `pages`: the parsed pages of one file, by `sha256` or by its exact `name` (a file of any age), from `from` to `to` (0-based, `to` excluded, one page by default). Each line comes as ` [x0,y0,x1,y1] `, the box normalized to the page. `contains` keeps the lines that hold a text, `max_lines` caps the lines (200 by default, at most 1,000), and `next` names the `from` and `line_offset` of the next call. | | `lbb_evals` | `summary`, `traces`, `trace`, `label`, `judge`, `goldens`, `golden`, `accept`, `delete`, `run`, `results`, `settings`. Model checks: `checks_summary`, `checks`, `review_check`. Datasets: `datasets`, `datasets_get`, `datasets_export`, `datasets_create`, `datasets_items`. | | `lbb_evals_delete` | Deletes the dataset `id` with its items, once the person approves. A later `datasets_get` or `datasets_export` of it fails with 404, and the next save of its workflow test makes a new dataset. | | `lbb_model_choice` | `options`: per use (`ask`, `route`, `rerank`, `fit`, `label`) the model it runs now, LBB's model, a switch, its checked calls and the models a trial can test. `trials` (`job`, `limit`): the trials with their reports. `trial` (`trial_id`): one trial with its compared calls. `trial_call` (`trial_id`, `call_id`): both answers and the ground truth. `switches`: the switched uses. `choices` (`workflow`, `use`, `cursor`, `limit`): the model choices of the workflow steps (`extract`, `decide`, `embed`): the environment's rows, and with `workflow` the row in force for each step of that workflow; one page in `data`, with `has_more` and `next_cursor`. | | `lbb_model_choice_manage` | `start`: test a candidate (`provider`, `model`, `effort`) on the uses in `jobs`, with an optional `target` and `days`; it changes no model. `stop` (`trial_id`). `switch` (`trial_id`): the use runs the candidate from the next call on; only a trial that qualifies and compared with the model in use. `revert` (`job`): back to LBB's model. Agents show the report and switch only what the user confirmed. `put_choice` (`use`, `provider`, `model`, `effort`, `workflow`): the step runs that model from its next call, for the environment or one workflow. `delete_choice` (`use`, `workflow`): the step runs the next level again; it answers `{id, object, deleted: true}`. | | `lbb_models` | `shadow_eval` (with a `body`), `suggest_dataset`, `extractor_dataset` (with `limit`, `split_seq`). `activity`: one month of the stack's managed-model use, by feature, day and graph (`month` as `yyyy-mm`; the current month by default). | With `lbb_evals`, an agent records what the user confirmed about an answer: 1. Run a query or search with `request`, the user's words. The result names a `trace_id`. 2. For a question, label the whole answer with `action: "label"`, `trace_id` and `valid`: `true` when the query is right for the question. With `valid: false`, `sparql` gives the right query. 3. For the results, read their ids with `action: "trace"` and label them with `item` and `valid`, or `items`. A search by meaning needs these labels: no query says which hits are right. `golden` saves a query and treats its current results as relevant. `run` checks every golden at the current commit: a question is asked again through Ask (`POST /v1/query/ask`, its answer loop), and a saved query or search runs again. The verdict of a question shows the loop's final query and its answer with the records it cites. `judge` lets the platform's judge model label unlabeled results. `lbb_evals` also reads the [model checks](https://docs.littlebigbrain.com/guides/check-model-quality/). A judge model checks a sample of the model calls LBB makes for the graph: - `checks_summary` sums a month per job and model, with the judge's agreement with people. `month` is `yyyy-mm`; the default is the current month. - `checks` lists the month's checks, newest first. Each has the judge's verdict, score and reason, and the ground truth. `job`, `verdict` and `reviewed` filter the list, and `after` reads the next page. - `review_check` records a review of one check by its `call_id`. `agree: true` keeps the judge's verdict. `agree: false` with `verdict` corrects it, with an optional `score`, `reference` and `note`. A review becomes the call's ground truth. Show the check to the user first, and review only what the user confirmed: ```text lbb_evals action=checks verdict=wrong reviewed=false lbb_evals action=review_check call_id= agree=false verdict=right note="The user confirmed the order." ``` #### Review items A review item is a question for a person. Workflows, integrations, agents and people file them, and the console's Review inbox shows them. Each kind takes its own decisions: | Kind | Asks | Decisions | | --- | --- | --- | | `change` | accept an ontology change, identities or facts | `accept`, `reject`, `cant_tell` | | `value` | confirm one value of a property | `accept`, `correct` (with `value`), `reject`, `cant_tell` | | `choice` | pick one of `question.options` | `choose` (with `option`), `cant_tell` | | `grade` | grade `question.output` on the scale `question.options` | `choose` (with `option`), `cant_tell` | | `answer` | answer `question.prompt` in words | `accept` (the text in `note`), `cant_tell` | An accepted or corrected `value` item commits the value to the graph. Show the item to the user, and answer only what the user decided: ```text lbb_reviews action=list status=open kind=value lbb_reviews action=get id=rev_… lbb_reviews_manage action=answer id=rev_… answer={"decision": "correct", "value": 45} revision=3 ``` An answer that accepts or corrects a `value` item, or accepts a `change` item, commits facts or applies an ontology change. It asks the person to approve first. See [Approve destructive writes](#approve-destructive-writes). An item has one answer. The same answer again returns the item. Another answer fails with `409 review_answered`. `revision` is the revision the person saw; a later one fails with `409 review_stale`. A list page holds at most the items the `detail` level shows: 5 (`compact`), 20 (`standard`) or 100 (`full`). The page then comes back whole, and `next_cursor` continues after the last item you read. Each write sends an idempotency key. A retry after a network failure or a 5xx sends the same key, so the write happens once. [Ask a person](https://docs.littlebigbrain.com/guides/review-in-workflows/) shows how workflows file items and use the answers. #### Workflow tests and datasets A [workflow test](https://docs.littlebigbrain.com/guides/test-a-workflow/) runs a `values.extract` workflow on a few inputs and writes nothing to the graph. The answers to its values become a dataset of examples: ```text lbb_workflows_manage action=tests_create name=sinks sample=6 lbb_workflows action=tests_get id=wtest_… lbb_workflows_manage action=tests_answer id=wtest_… iri= property=width_cm answer={"decision": "correct", "value": 45} lbb_evals action=datasets_create name=sinks source={"kind": "test", "id": "wtest_…"} lbb_evals action=datasets_export id=ds_… ``` `datasets_create` also takes the examples themselves: `name`, `items`, `install` (each property's value type, the units, the context) and `about`. Each item has a `name`, its `text`, the `values` a correct run writes and the ones it must `leave_out`. `datasets_items` adds items to a dataset; an item with the name of one it holds replaces it. `lbb_evals_delete` deletes a dataset by `id`, once the person approves. A dataset ends with its graph. Storage inspection and other operator actions are not in the tool set. Publication and cleanup run automatically. ### Embed in a Node process The package exports three functions: | Export | What it does | | --- | --- | | `createMcpHttpServer(options)` | Returns a Node `http.Server` that serves the tools over streamable HTTP. Each caller sends a stack API key as a bearer token. | | `buildLbbServer(client, options?)` | Returns an MCP server bound to one `LbbClient`. | | `registerLbbTools(server, client, options?)` | Adds the tools to an MCP server you already have. | This process serves the tools on `http://127.0.0.1:8080/mcp`: ```ts import { createMcpHttpServer } from "@littlebigbrain/mcp"; const server = createMcpHttpServer({ baseUrl: process.env.LBB_URL!, mcpPath: "/mcp", allowedHosts: ["127.0.0.1", "localhost"], onError: (error) => console.error("MCP request failed", error), }); server.listen(8080, "127.0.0.1"); ``` A client sends `POST /mcp` with a JSON body and `Authorization: Bearer `. Add `?graph=` to change the default graph. `GET /healthz` needs no key. | Option | Default | Meaning | | --- | --- | --- | | `baseUrl` | Required | The complete stack endpoint. | | `mcpPath` | `/mcp` | The path that serves MCP. | | `maxBodyBytes` | 1,048,576 | Larger bodies get `413`. | | `requestTimeoutMs` | 30,000 | The request timeout, in milliseconds. | | `allowedHosts` | None | Host names that the `Host` header must match. A present `Origin` must match too. Others get `403`. Set it when the server is reachable from a browser. | | `allowedOrigins` | None | Extra exact `Origin` values to accept. | | `onError` | None | Called with each failed request's error. | | `clientFactory` | `new LbbClient(…)` | Builds the `LbbClient` for each request from `baseUrl`, `apiKey` and `graph`. | | Response | Cause | | --- | --- | | `401` | No bearer token. | | `415` | The `Content-Type` is not `application/json`. | | `400` | The body is not valid JSON. | | `413` | The body is larger than `maxBodyBytes`. | | `403` | The `Host` or `Origin` is not allowed. | | `404` | Another path, or a method other than `POST` on `mcpPath`. | | `500` | An unexpected error. The body is `internal server error`, and `onError` receives the details. | This server has no OAuth. Put your own authentication in front of it before you expose it to a network. The hosted OAuth endpoint is a separate service. To add the tools to your own MCP server, next to your own tools: ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { LbbClient } from "@littlebigbrain/client"; import { registerLbbTools } from "@littlebigbrain/mcp"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); const server = new McpServer({ name: "support-agent", version: "1.0.0" }); registerLbbTools(server, lbb); await server.connect(new StdioServerTransport()); ``` `buildLbbServer(lbb)` does the same with a new server named `lbb`. Both accept these options: | Option | Default | Meaning | | --- | --- | --- | | `queryTextFormat` | `compact` | `compact` or `pretty`: the format of query results. | | `destructiveWithoutApproval` | `allow` when `LBB_MCP_DESTRUCTIVE=allow`, else `refuse` | What a call that deletes or removes data does when the client cannot ask the person. See [Approve destructive writes](#approve-destructive-writes). | | `log` | A JSON line on standard error | Receives one event per tool call. | ### Related - [Quickstart](https://docs.littlebigbrain.com/start/quickstart/): the `support` graph these examples use. - [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/): embeddings, filters and search planning. - [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): SPARQL and SHACL in depth. - [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/): the `entailment` regimes. - [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/): pin a read to a commit and replay it. ## HTTP API & SPARQL protocol URL: https://docs.littlebigbrain.com/sdks/http-api/ Source: docs-site/src/content/docs/sdks/http-api.md Description: The HTTP routes every SDK wraps. Auth, idempotency, errors, writes, SPARQL reads, search by meaning, schema, and the native SPARQL 1.1 Protocol endpoint. Every SDK wraps one HTTP API. Call it directly from any language. Standard SPARQL clients connect to the same stack through the SPARQL 1.1 Protocol. The TypeScript and Python SDKs are generated from the same API contract. Their request and response shapes match. To see the calls in order, run the [quickstart](https://docs.littlebigbrain.com/start/quickstart/) with its cURL tabs. The examples use the quickstart's `support` graph of tickets, bugs and releases. The RDF examples use a second graph, `catalog`. The generated contract also includes experimental `/v1/cdc/*` capture/apply routes. They remain disabled by default and require a reviewed server rollout; they are not yet a hosted PostgreSQL connector. Their short-lived worker tokens are role- and source-scoped and cannot be used for ordinary graph or workflow requests. Captured, graph-applied and published positions are separate: capture acknowledgement alone does not imply query visibility. The experimental capture and apply modes are independent. A capture pause fences new source writes while allowing already captured batches to reach the graph. An interrupted snapshot requires a new attempt; resume never guesses a replacement snapshot boundary. ### Discover the APIs and specifications Fetch [the API catalog](https://littlebigbrain.com/.well-known/api-catalog) to discover the graph/search API and the developer integrations API. It returns `application/linkset+json`, with `service-desc` links to OpenAPI 3.1 JSON, `service-doc` links to documentation and `status` links to public health checks. The homepage's `Link` response header also advertises the catalog. The [graph/search specification](https://littlebigbrain.com/openapi/data-plane.json) contains the same paths and schemas used to generate the SDKs. Its server variables are documentation examples: use your complete stack endpoint from **Connect** for actual requests. The catalog's EU service anchor identifies the data-plane service; its health link reports the cell's status. The [integrations specification](https://littlebigbrain.com/openapi/integrations.json) describes the `/v1/integrations/*` routes on `api.littlebigbrain.com`; see [integrations for your customers](https://docs.littlebigbrain.com/guides/integrations-for-your-customers/). ### Conventions - **Base URL.** Use `$LBB_URL`, the complete stack endpoint from the console's **Connect** view. It has the form `https://--.db.eu.littlebigbrain.com`. See [Base URLs](https://docs.littlebigbrain.com/start/authentication/#base-urls). - **Auth.** Send `Authorization: Bearer $LBB_API_KEY` on every request. `GET /version` needs no key. - **Graph.** `?graph=` selects a graph in the stack. `?graph_id=` is an alias, and the default is `main`. A name has 1 to 32 characters: lowercase letters, digits, `-` and `_`. It starts with a letter or a digit. - **One history per graph.** A graph has no branches. `branch=main` is accepted. Any other `branch` or `branch_id` value returns `400 branch_not_supported`. - **Version.** Prepared source contract `2026-10-27` is unreleased; inspect `GET /version` for the running binary. Send `Lbb-Version: 2026-10-27` to pin this prepared contract. Matching source SDKs send it on every request. The advertised dates are `2026-07-23`, `2026-10-21` and `2026-10-27`. A request without the header gets the current contract. A request with a date before `2026-10-21` gets the list shapes of `2026-07-23` (see [Lists](#lists)). The value is a date `YYYY-MM-DD`; anything else answers `400 invalid_api_version`. - **Content type.** Requests and responses are JSON, except on the RDF and SPARQL routes. Those take their own media types. - **Compression.** Send `Accept-Encoding: zstd` or `gzip` to get compressed responses. Most HTTP clients do this for you. Bodies under 1 KiB stay plain. - **Body size.** A JSON body can be up to 1 MiB. NDJSON import, RDF import, SPARQL and the Graph Store route take up to 64 MiB. A durable import job takes up to 32 GiB. A larger body returns `400`. A file upload takes up to 1 GiB by default; a larger one returns `413 file_too_large`. ### Idempotency An `Idempotency-Key` header makes a write safe to retry. A retry with the same key returns the first result and writes nothing new. The response marks it as a replay. - These routes require a key: `POST /v1/graph/commit` (except with `?dry_run=true`), `POST /v1/graph/import-jobs` and `POST /v1/ontology/drafts/promote`. Without one they return `400 missing_idempotency_key`. - Other writes, such as RDF import, NDJSON import and SPARQL Update, accept an optional key. Without one, the server makes a key for that request. - A key has 1 to 255 characters: letters, digits, `-`, `_`, `.`, `:` and `/`. Any other key returns `400 invalid_idempotency_key`. - A commit that reuses a key returns the first result, even when its body changed. Use a new key for a new payload. - An import job whose key is bound to other content returns `409 idempotency_conflict`. The SDKs add a key when you pass none. ### Lists A list route returns one page in the list envelope: ```json { "object": "list", "data": [{ "object": "workflow", "name": "notes", "created": "2026-10-21T09:00:00Z" }], "has_more": true, "next_cursor": "notes" } ``` `data` holds the items of the page. `has_more` says whether more follow. Pass `next_cursor` as `cursor` for the next page; it is absent on the last page. `limit` sets the page size. Treat a cursor as an opaque string. The workflow lists use the envelope: `GET /v1/workflows/named`, its `runs`, `queue` and `documents`, and `GET /v1/workflows/instances/runs`. A list can add fields of its own beside the envelope: `total` on the documents, the queue's `pending` and `shards`. The SDKs page through each one (`all()`, `allRuns()` and `allDocuments()` in TypeScript; `all()`, `all_runs()` and `all_documents()` in Python). A request with an `Lbb-Version` before `2026-10-21` (`2026-07-23` and earlier) gets these lists in the shapes of its date. The items are in `workflows`, `runs`, `items` or `documents`. The cursor is `next_before` (a run id, passed as `before`) or `next_after` (passed as `after`; a number on the queue). These requests take `before` and `after` as well as `cursor`. ### Responses and errors Every response carries these headers when they apply: | Header | Meaning | | --- | --- | | `X-Request-Id` | The id of this request. Quote it when you report a problem. | | `Retry-After` | Seconds to wait before you retry a retryable error. | | `X-LBB-Throttle-Ms` | Set on a write that waited in a queue. See [Back-pressure](#back-pressure). | | `Lbb-Version` | The API version marker, the same value as `version` from `GET /version`. | | `Lbb-Build-Commit` | The build of the server that answered. | | `Server-Timing` | Timings of the request's stages, on routes that report them. | An error has a JSON body with one `error` object: ```json { "error": { "type": "invalid_request_error", "code": "missing_idempotency_key", "message": "missing Idempotency-Key header", "param": "Idempotency-Key", "request_id": "89ccd5ab-e545-4744-9909-769b393bb6d1", "doc_url": "https://docs.littlebigbrain.com/sdks/errors/#missing_idempotency_key" } } ``` Handle errors by `code`. `doc_url` links the code's entry on [Error codes](https://docs.littlebigbrain.com/sdks/errors/), which lists every code with how to fix it. `param` names the field, query parameter or header to change, when the error has one. A retryable error also carries `retryable: true` and `retry_after_seconds`. A path that matches no route returns `404 not_found` with `did_you_mean`, a list of close routes. The SDKs raise this body as `LbbError`. | Status | `code` | Meaning | Retry | | --- | --- | --- | --- | | 400 | `invalid_request` | The request is malformed. The `message` names the problem. | No | | 400 | `missing_idempotency_key`, `invalid_idempotency_key` | See [Idempotency](#idempotency). | No | | 400 | `branch_not_supported` | The request names a branch other than `main`. | No | | 400 | `rdf_native_graph` | A JSON record write on an RDF-native graph. See [RDF-native graphs](#rdf-native-graphs). | No | | 400 | `historical_generation_unavailable` | The pinned commit is not retained. See [Time travel](#time-travel). | No | | 400 | `scan_too_wide`, `result_too_large` | The query reads or returns too much. Narrow it. | No | | 400 | `invalid_ask_request` | A field of `/v1/query/ask` is outside its limits, or belongs to the removed one-shot mode (`run`, `previous`, `mode: "rewrite"`). `param` names it. | No | | 400 | `invalid_names_request`, `invalid_describe_request`, `invalid_commit_at_request`, `invalid_compare_request` | A field of `/v1/query/names`, `/v1/query/describe`, `/v1/graph/commit-at` or `/v1/query/compare` is outside its limits. `param` names it. | No | | 400 | `commit_not_found` | `/v1/query/compare`: the server cannot place a date or a moment (before the first commit, or commits without a time). | No | | 401 | `unauthorized` | The key is missing, wrong or revoked. | No | | 403 | `stack_endpoint_mismatch` | Hosted stacks: the key belongs to another stack. Copy the endpoint and key from the same stack. | No | | 403 | `write_outside_owns` | A commit under a workflow turn writes a property its workflow does not own. Or it replaces the whole property set of an entity. `details` names the IRIs. Nothing was written. | No | | 404 | `not_found` | No such graph, record, job or route. | No | | 409 | `idempotency_conflict` | The key is bound to different import content. | No | | 409 | `conflict` | A write precondition failed, or the resource already exists. | No | | 409 | `embedding_not_ready` | The embedding's first version still builds. | Yes | | 409 | `workflow_fence_lost` | A commit's `workflow_fence` no longer holds its turn: the lease ended or another worker holds it. Nothing was written. | No | | 409 | `turn_lease_lost` | A turn route's `token` no longer holds the turn: another attempt holds it, its lease ended, or it finished. Stop this attempt. | No | | 410 | `pagination_cursor_expired` | A SPARQL page cursor can no longer be served. Restart the listing. | Restart | | 421 | `stack_endpoint_required` | Hosted stacks: the host is missing or malformed. | No | | 429 | `server_busy`, `ingest_busy` | The server or the graph is busy. See [Back-pressure](#back-pressure). | Yes | | 429 | `read_your_writes_pending`, `strong_read_pending` | The read waits for a commit. See [Read consistency](#read-consistency). | Yes | | 429 | `index_busy` | An index build for this graph is running, or the server is at its build limit. | Yes | | 429 | `embedding_budget_exceeded` | The monthly model budget is spent. | After the reset date | | 429 | `rewrite_limit` | The environment used its questions of the UTC day on this server (`/v1/query/ask`). | The next UTC day | | 503 | `query_deadline_exceeded` | The read ran past its deadline. | Yes | | 503 | `storage_degraded` | Object storage is unreachable. | Yes | | 503 | `rewrite_model_unavailable` | The model of `/v1/query/ask` did not answer, or the server has none. | Later | ### Routes Routes that read or write a graph take `?graph=`. The sections below and the linked guides describe the main routes in more detail. #### Stack and graphs | Method & path | Purpose | | --- | --- | | `GET /version` | Build identity, API version and capability list. No key needed. | | `GET /v1/status` | Server and graph status. `graph_status.truth` is `property_graph` or `rdf_native`. | | `GET /v1/usage` | Usage counters for your stack. | | `GET /v1/graphs` | Lists the graphs in the stack. Takes `?limit=` and `?cursor=`. | | `POST /v1/graph/create` | Creates the graph with an empty schema. `POST /v1/ontology/define` also creates it. | | `POST /v1/graph/fork?src=&dst=&confirm=` | Copies a graph to a new graph id in the same stack, as a background job. `dst` must be a new graph id. The id of a deleted graph is refused with `409`. The copy keeps the data, its history, the schema and the shapes. It also keeps the embeddings with their vectors and the rewrite profile. It publishes and takes writes on its own. Poll `GET /v1/graph/metadata?graph=`. | | `POST /v1/graph/delete?graph=&confirm=` | Deletes the graph. Safe to repeat. Storage is freed in the background. A graph created again under the same name starts empty, and late work from the deleted graph cannot change it. | | `GET /metrics` | Prometheus metrics for the people who run the servers. A stack key gets `401` on hosted stacks. | #### Write | Method & path | Purpose | | --- | --- | | `POST /v1/graph/commit` | Writes links and field values as JSON in one commit. Needs `Idempotency-Key`. See [Write records as JSON](#write-records-as-json). | | `POST /v1/graph/import` | Imports NDJSON records inside the request, up to 64 MiB. | | `POST /v1/graph/import-jobs` | Starts a durable NDJSON import job, up to 32 GiB. Needs `Idempotency-Key`. See [Bulk NDJSON ingest](#bulk-ndjson-ingest). | | `GET /v1/graph/import-jobs?job_id=` | Job state, progress and result. | | `DELETE /v1/graph/import-jobs?job_id=` | Asks the job to stop. | | `POST /v1/graph/import/rdf` | Loads N-Triples, Turtle, N-Quads or TriG, up to 64 MiB per request. See [Load RDF](#load-rdf). | | `POST /v1/graph/retract` | Retracts `edges`, or every current link of the records in `entities`. `purge_entity_properties: true` also clears those records' fields. Past commits keep the data. | | `POST /v1/graph/reload?confirm=` | Makes the graph's current state match an NDJSON payload in one step. Records absent from the payload leave the current state. `?dry_run=true` previews the change. Also takes `strict` and `observed_at`. | | `POST /update`, `POST /v1/update` | SPARQL Update. `INSERT DATA` only. See [SPARQL Update and graph documents](#sparql-update-and-graph-documents). | | `POST /rdf-graph-store?default` | Appends a Turtle or N-Triples document to the graph. | #### Query with SPARQL | Method & path | Purpose | | --- | --- | | `GET`, `POST /sparql` | The SPARQL 1.1 Protocol endpoint. Results follow the `Accept` header. See [Native SPARQL 1.1 Protocol](#native-sparql-11-protocol). | | `POST /v1/query/dataset` | SELECT/ASK over one primary graph and up to four authorized reference graphs. Takes explicit `references` or an immutable `view_revision`; see [Reference datasets and saved views](#reference-datasets-and-saved-views). | | `POST /v1/query/sparql-text` | A SPARQL SELECT or ASK query in a JSON body. Returns SPARQL Results JSON as a string in `results`. | | `POST /v1/query/sparql` | A structured query as JSON: patterns, filters, grouping, aggregates, ordering and ASK. | | `POST /v1/query/ask` | A question in plain words. A model runs queries in a short loop and answers. Returns the answer with the records it cites, the route, and the query whose rows hold the answer, with its rows. `POST /v1/query/rewrite` was removed on 2026-10-05. | | `POST /v1/query/names` | The records a text names: per name its candidates, the one to prefer first. No model call. | | `POST /v1/query/describe` | The classes and properties a question needs: how many sampled instances hold each property, the values of small classes, examples and schema statements. No model call. | | `GET /v1/graph/commit-at` | The commit of `date=YYYY-MM-DD` (by the end of that day, UTC) or `moment=`. | | `POST /v1/query/compare` | One SELECT at two points (`before`, `after`: a commit, a date or a moment), the rows paired by `key` into `added`, `removed` and `changed`, with totals and a cursor. | Compare's `next_cursor` is an opaque, transient continuation of the same query, row budget and two snapshot commits in one tenant, graph, branch and graph incarnation. Send it unchanged with the same comparison request. Later writes in that incarnation do not move its snapshots. Against a live graph, an unsupported cursor version or a token for another resource or a prior incarnation returns `400 invalid_compare_request` with `param: "cursor"`; restart by omitting `cursor`. A graph that is already missing or deleted keeps the existing `404` response. An incarnation change during the fenced comparison reads returns `409 graph_epoch_changed`. Do not parse cursors, use them as identifiers, or reuse them across graphs. The version2 scope digest binds identity; it does not grant access. `/v1/query/sparql-text` takes these body fields: | Field | Meaning | | --- | --- | | `query` | The SPARQL text. CONSTRUCT and DESCRIBE return `400`; send them to `/sparql`. | | `as_of_commit_seq` | Reads a past commit. See [Time travel](#time-travel). | | `limit`, `offset` | Row window over the SELECT result. | | `cursor` | Page through an ordered result. Send `""` to start, then the `next_cursor` of the previous page with the same query. The query needs one ordered `LIMIT` from 1 to 10,000, no `OFFSET`, and no `limit` or `offset` field. | | `entailment` | `none` (default), `subclass`, `rdfs` or `owl`. See [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/). | | `reason` | `true` returns an error. Stored rules already ran when the data was published. | | `request` | The user's words behind the query. The response then carries a `trace_id` for [evals](#evaluate-answers). | | `route` | With `request`: how your app routed the question, such as the `route` of `/v1/query/ask` with `mode: "route"`. The eval trace keeps it. It does not change the query. | The response has `results` and `row_page`, with `limit`, `offset`, `returned`, `total` and `has_more`. It adds `next_cursor` when a next page exists, and `snapshot` on an eventual read. `/v1/query/ask` takes `question` and the optional `context`, `route`, `mode`, `limit`, `as_of_commit_seq`, `today`, `include_grounding`, `anchor` and `timeline`. Its response has `answer` (`text`, `citations`, `chart`), `steps`, `route`, `query`, `rationale`, `attempts`, `grounding`, `models` and `timings`. `result` is the response of `/v1/query/sparql-text` for the query whose rows hold the answer, with a `trace_id`. When the loop stopped before it answered, `error` says why. `linked` lists the names of the question the server found in the graph, and `anchors` what it read about each IRI of `anchor`. A history answer adds `history`: the commit of its date, and for a comparison the rows `added`, `removed` and `changed`, with `totals`. `mode: "route"` returns the route alone. With `Accept: text/event-stream` the steps come as server-sent events, and the last event, `done`, holds the response. [Answer questions in plain words](https://docs.littlebigbrain.com/guides/answer-questions/) covers each field and [each step](https://docs.littlebigbrain.com/guides/answer-questions/#show-each-step). ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/ask?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"question": "Which release fixed the login bug?"}' ``` #### Reference datasets and saved views Reference datasets and saved views are optional and disabled by default. Each requires its deployment feature to be enabled. The quickstart still needs only a graph and a token; an administrator may save a selection when an app reuses it. | Method & path | Purpose | | --- | --- | | `POST /v1/catalog/views` | Environment administrator only. Save an immutable source definition; returns `{revision, definition}`. The same definition in the same environment returns the same revision. | | `GET /v1/catalog/view?revision=` | Environment administrator only. Retrieve that exact saved definition. | | `POST /v1/query/dataset?graph=` | Query an explicit selection or saved revision. The query credential must authorize every source; the view grants no access. | The save body names the primary graph, its exact epoch, and at most four reference graphs in the same environment: ```json { "primary_graph_id": "project", "expected_graph_epoch": "7", "references": [ { "name": "urn:standards", "graph_id": "standards", "expected_graph_epoch": "3" } ] } ``` Epochs are decimal strings. A reference's `name` is the absolute IRI used in `GRAPH`, not an access grant. The primary is the default graph. The definition and each reference may include `as_of_commit_seq` to require an exact published commit; saving a view does not retain older generations or copy graph data. A transient publication failure returns a retryable 503; retrying the same definition converges on its immutable revision. A missing-view lookup returns 404. Keep the returned revision. To query it, send this body to `/v1/query/dataset?graph=project`, replacing `` with that value: ```json { "view_revision": "", "references": [], "expected_graph_epoch": "7", "query": "SELECT ?standard WHERE { GRAPH { ?standard } }" } ``` The `references` field is always required; use `[]` with `view_revision`. The primary graph and epoch must match the saved definition; if the query supplies `as_of_commit_seq`, it must also match. Every source must remain authorized and have the required published RDF generation. The response contains SPARQL Results JSON in the string `results`, independently pinned `sources`, and `charged_read_bytes`. It is not an atomic cross-graph snapshot. A view revision is immutable; there is no mutable latest pointer, namespace subtree selection or implicit authority grant. SDK methods are TypeScript `saveDatasetView`, `getDatasetView`, `sparqlDataset` and Python `save_dataset_view`, `get_dataset_view`, `sparql_dataset`. See [How to model your app](https://docs.littlebigbrain.com/start/model-your-app/#save-a-reference-selection-for-reuse). #### Read records | Method & path | Purpose | | --- | --- | | `GET /v1/graph/entity` | One record: typed `attributes` and current links, read at one commit. See [Read one record](#read-one-record). | There is no route that lists every record or every link. Use SPARQL and point reads. #### Search by meaning | Method & path | Purpose | | --- | --- | | `GET /v1/embeddings` | The graph's embeddings with their status: serving and building version, progress, watermark and lag. `?name=` returns one. | | `PUT /v1/embeddings` | Declares or changes the embedding of a class: its `class`, `from` fields, `model` and `title`. A new recipe builds a new version while the current one serves. | | `DELETE /v1/embeddings?name=&confirm=` | Removes an embedding and its vectors. | | `POST /v1/embeddings/preview` | Shows what a declaration would embed: resolved fields, candidate facts and sample texts. Stores nothing and calls no model. | | `PUT /v1/embeddings/model` | Moves every embedding of the graph to another model. | | `POST /v1/embeddings/refresh?name=` | Runs one bounded step of the embed job now. | | `POST /v1/search` | Search by meaning over every searchable class, with filters by class and by link. Every hit is checked against one published commit. | [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) covers the request fields, the response and the errors. #### Workflows A workflow has a name, unique on the graph. The routes under `/v1/workflows/named` read and change a workflow by that name. A workflow made from one of LBB's templates (a starter workflow) is created with `PUT /v1/workflows/triggered`, and previewed and calibrated there too. | Method & path | Purpose | | --- | --- | | `GET /v1/workflows/starters` | The catalog: every starter workflow this server offers, with its group, its settings, the graph kinds it supports, and whether it can run here. | | `PUT /v1/workflows/triggered` | Creates a workflow from a starter workflow on a class, or changes one: its `starter`, `watch` (`class`, `fields`), `params` and `name`. A developer workflow (`starter: "workflow"`) also takes `workflow` (`workflow_type`, `version`), `owns` and `batch`; `documents.link` and `values.extract` take `gate` and `budget_usd_per_month`. The same request again changes nothing. | | `POST /v1/workflows/triggered/preview` | Shows what creating a workflow would do: the resolved watch, the instance count and the starter workflow's own preview. Writes nothing. `propose: true` runs the models of `ontology.fit` on the sample. | | `GET /v1/workflows/triggered/calibration` | The gates of the gated workflows (`documents.link`, `review`, `values.extract`), measured from review decisions, with a proposed gate. | | `POST /v1/workflows/triggered/calibration/apply` | Sets a gate from its measurement: `{starter, name, gate?}`. A person in the console only. | | `GET /v1/workflows/named` | The graph's workflows by name, in the list envelope (`limit` 1 to 200, default 100; `cursor`): each one's `object` (`workflow`), `created`, target workflow type, shards, routing field, window, trigger, status and shard inboxes. LBB's own workflows list here too, with an `lbb.*` target. `?name=` returns one workflow. | | `PUT /v1/workflows/named` | Creates or changes a workflow of your worker: `name`, `workflow`, one trigger, and `shards`, `route_by`, `window`, `batch`, `owns`, `on_exhausted` (`block` or `skip`), `budget_usd_per_month` and `fan_in` (a fan-in identity `starter..`). The trigger is a `watch`, a `content` trigger (`{"content": {"media_types": ["application/pdf"]}}`), which sends each upload of those media types, one file per message, without a wait for a publication, a `schedule` (`{"schedule": {"cron": "0 3 * * *", "timezone": "Europe/Berlin"}}` or `{"schedule": {"every": "1h"}}`), which sends one message per slot, or a `stream` (`{"stream": {"max_wait": "10s"}}`), which sends the events you post in batches. A change of `shards` waits for empty inboxes (`409 workflow_shards_busy`). | | `POST /v1/workflows/named/run?name=` | Runs the workflow now and answers at once with `queued`. | | `POST /v1/workflows/named/pause?name=` | Body `{"paused": true}` pauses the trigger (a watch, a content trigger, a schedule or a stream) and its workflow instances. `{"paused": false}` resumes both. A paused schedule skips its slots; a paused stream takes events and sends none. | | `POST /v1/workflows/named/events?name=` | Body `{"events": [{id, key?, type?, time?, data?}]}`, 1 to 500 events: sends them to a workflow with a stream. Each `id` is the event's idempotency key, so the route takes no `Idempotency-Key`. `429 workflow_stream_rate_limited` or `workflow_stream_full`, with `Retry-After`. | | `DELETE /v1/workflows/named?name=&confirm=` | Deletes the workflow. What it wrote stays. | | `GET /v1/workflows/named/queue?name=` | What the trigger has not sent yet, in the list envelope (`limit` 1 to 100, default 50; `cursor`): each record with its shard and origin. Beside the envelope: `pending`, the pass in progress, and each shard's messages. | | `GET /v1/workflows/named/runs?name=` | The workflow's runs, newest first, in the list envelope: origin, outcome, error, the queue before and after, what each run finished, its steps and phases, and the usage of its model calls. `cursor`, `limit` (1 to 200, default 50) and `outcome` page and filter them. Kept 30 days. | | `GET /v1/workflows/named/runs/get?name=&run=` | One run with its model calls in time order. | | `GET /v1/workflows/named/runs/stats?name=&hours=` | The workflow's runs by hour (24 hours by default, at most 168): runs, units, calls, tokens, cost, failed runs, queue and time. | | `GET /v1/workflows/named/runs/results?name=` | What the runs did with each input, newest run first, in the list envelope: the input, its outcome (`written`, `review`, `no_value`, `failed`, `duplicate`) and its values with their quotes. `run`, `outcome` (a comma list), `iri`, `cursor` and `limit` (1 to 200, default 50). With `run`: `run_counts` beside the envelope. Kept 30 days. See [Run results](https://docs.littlebigbrain.com/guides/workflows/#run-results). | | `GET /v1/workflows/named/documents?name=` | The documents of a `documents.link` or `documents.map` workflow, in the list envelope, with `total`, the documents that match `q`. `q` finds them by name, `sort` is `review`, `name` or `recent`, `cursor` and `limit` (1 to 200, default 50) page them. | | `GET /v1/workflows/instances/runs?id=` | The runs of one workflow instance, in the list envelope. Also `/runs/get` and `/runs/stats`. | | `POST /v1/workflows/named/requeue?name=` | Body `{workflow_id, turn, items: [{instance, error}]}`: sends the instances a turn could not do back to the watch, after 1, 4, then 16 minutes, at most 3 times. The turn completes, so the shard goes on. | | `POST /v1/workflows/blobs?workflow_id=` | Stores the request body (at most 8 MiB) for a workflow and returns its `key`, `/`. The same bytes again return the same key. | | `GET /v1/workflows/blobs?key=` | The bytes of a blob. Blobs go with their workflow and with the graph. | | `POST /v1/workflows/derivations/check` | Body `{sha256, workflow_type, version, settings_digest}`: whether this version of the workflow made something of the file with these settings (`exists`, `derivation`), and the upload of the live file (`content_uploaded_at`, absent for a deleted file). | | `POST /v1/workflows/derivations/record` | The same key with `run_id`, `outputs` (at most 16 KiB of JSON) and the check's `content_uploaded_at`. Keeps the first record of each key; a second record answers it with `recorded: false`. When the file was deleted since the check, it records nothing: `409 derivation_upload_changed`. | The workflows of `search.embed` and `ontology.fit` read and write the documents of `/v1/embeddings` and `/v1/ontology/fit-sources`. A developer workflow sends the instances to a workflow of your worker and needs a server with the workflow engine. The workflows of `documents.parse`, `documents.link` and `values.extract` read the graph's files and text and write the facts they own. [Starter workflows](https://docs.littlebigbrain.com/guides/starter-workflows/) covers the fields, the status and the errors, and [From documents to a graph](https://docs.littlebigbrain.com/guides/documents-to-graph/) the document workflows. #### Files | Method & path | Purpose | | --- | --- | | `POST /v1/files?name=` | Uploads a file: the request body is its bytes, at most 1 GiB by default (`413 file_too_large` above it), with its `Content-Type`. An empty body is an empty file. Stores the bytes once under their SHA-256 and commits a `Document` record for them, with a `Content` record of the bytes' `sha256` and `byte_length` (linked by `HAS_CONTENT`). Optional `language` (at most 64 characters) and `schema` (at most 256) are stored on the `Document`. Without a `Content-Type` (or with `application/octet-stream`) the server detects PDF, PNG, JPEG, GIF and WebP by their bytes, `.xlsx`, `.docx` and `.pptx` by the ZIP signature and the name, and UTF-8 text named `.csv`, `.tsv`, `.md`, `.json` or `.txt` by the name. The same bytes under the same name again change nothing (`created: false`). `409 file_generated` for the bytes of a generated file (one a workflow run wrote). | | `GET /v1/files?limit=&after=` | One page of the graph's files, the newest upload first: `sha256`, `name`, `content_type`, `language` and `schema` when given, `bytes`, `uploaded_at`, the `document` IRI, and `parse` once the parse workflow read the file. Without `limit`, the newest 1,000 (`limit` is 1 to 1,000). `next` is the `after` of the next page and is absent at the end; a page can hold fewer than `limit` files while `next` is set. `parse_status=` (`parsed`, `unsupported`, `failed`, or `waiting` for none) and `name=` (a part of the name, in any case) filter the list over the `Document` records of the published generation, with the commit it holds (`as_of_commit_seq`). | | `GET /v1/files/record?sha256=` | One file's record: the same fields as one file of the list. `404 file_not_found` for a SHA-256 the graph has no file for, or a deleted one. With `deleted=true`, a deleted file whose bytes are not freed yet answers with `deleted_at`, and each answer holds `cited_by`: the facts whose evidence names the file, at most 20. | | `GET /v1/files/counts` | The graph's files by parse status (`files`, `by_parse_status`; `waiting` before the parse), counted over the `Document` records of the published generation, with the commit it holds (`as_of_commit_seq`). | | `GET /v1/files/content?sha256=` | The file's bytes, with its content type, streamed from storage. One `Range: bytes=a-b`, `bytes=a-` or `bytes=-n` returns `206` with that part and `Content-Range`; a range with no byte of the file returns `416 range_not_satisfiable`. `ETag` is the SHA-256 in quotes. The response sends `X-Content-Type-Options: nosniff` and `Content-Disposition: attachment` with the file name (`filename=""` and the RFC 8187 `filename*=UTF-8''`), so a browser downloads the bytes and does not render them. | | `HEAD /v1/files/content?sha256=` | The file's `Content-Length`, `Content-Type`, `ETag` and the `X-Content-Type-Options` and `Content-Disposition` headers of the GET, with no body. | | `GET /v1/files/pages?sha256=&from=&to=` | The parsed pages `from` up to `to` (0-based, `to` excluded; default 10 pages, at most 50): each page's `width` and `height` in PDF points and its lines, each with its `id`, `text`, `box` and `words`. A box is `[x0, y0, x1, y1]` normalized to the page, origin top left. `404 file_not_parsed` before the parse. | | `GET /v1/files/sheets?sha256=&sheet=&from=&to=` | The sheets of an XLSX workbook, or the rows of one sheet. Without `sheet`, every sheet with its name, state and size and no rows. With `sheet`, its rows `from` to `to` (A1 rows from 1, both included; default 100 rows, at most 1,000 and 20,000 cells; `next` names the row to go on from). A cell has its stored `value` as text and its `formula` apart; nothing is calculated. The first read parses the workbook in a bounded child process. `400 file_not_spreadsheet`, `400 xlsx_refused`, `404 sheet_not_found`, `503 xlsx_busy`. | | `PUT /v1/files/pages?sha256=&parser=` | Stores what a parser outside the server made of a file. The JSON body is `{"uploaded_at": "...", "status": "parsed", "pages": [...]}` with every page in the shape `GET /v1/files/pages` serves, or `{"uploaded_at": "...", "status": "failed", "error": "..."}` (also `unsupported`). `uploaded_at` is the file record's, so the pages belong to that upload: a file deleted and uploaded again since answers `409 file_upload_changed`. The server checks the pages and stores them once under the parser id (1 to 64 letters, digits, `.`, `_` or `-`). The same pages again store nothing (`stored: false`), so a retry is safe; other pages under the same id answer `409 file_pages_conflict`. The file's `parse` follows. Your parser writes the `Document`'s properties with its own commit. | | `DELETE /v1/files/expiry?sha256=` | Keeps a generated file: removes its `expires_at`, so it stays until a person deletes it and shows in `GET /v1/files`. Answers the file record. A repeat answers the same record. `404 file_not_found` for no file or a deleted one. | | `DELETE /v1/files?sha256=&confirm=` | Removes the file from the list and retracts its `Document` and `Content` records. The bytes and what the workflows made of the file stay for 30 days. Then they go, unless a fact's evidence still names the file. `purge=true` also retracts those facts (`purged_edges`). An upload of the same bytes while they go answers `429 file_reclaim_in_progress`; retry it. | Files work on records graphs and RDF-native graphs. On an RDF-native graph the `Document` gets the same statements and IRIs a records graph shows. An unknown SHA-256 answers `404 file_not_found`. #### Evaluate answers | Method & path | Purpose | | --- | --- | | `GET /v1/evals` | Summary: settings, golden counts, unlabeled traces, the latest run and the score by commit. | | `GET /v1/evals/traces` | Recent traces, newest first. Takes `?limit=` and `?unlabeled=`. | | `GET /v1/evals/trace?id=` | One trace: the request, its query and its type, its results and their labels. | | `POST /v1/evals/label?trace=` | Labels results of a trace as relevant or not. On a question, `valid` alone labels the answer: `true` makes its query the golden query, `false` with `sparql` makes that query the golden query. `route` with such a label names the route the question expects; `true` alone takes the trace's route. | | `POST /v1/evals/judge` | Lets the managed judge label one trace (`?trace=`) or a batch (`?limit=`). | | `GET /v1/evals/goldens` | The golden suite: questions, searches and stored queries, with their golden queries and judged results. | | `POST /v1/evals/goldens` | Freezes a stored query or a search and the results it returns now as a golden. A question becomes a golden through a label. | | `POST /v1/evals/goldens/accept?id=` | Accepts what a golden returns now as its new reference. A question is asked again, and the final query of Ask's answer loop becomes its golden query. | | `DELETE /v1/evals/goldens?id=` | Deletes a golden. | | `POST /v1/evals/run` | Checks every golden at the newest commit and records the verdicts. A question is asked again through Ask (`POST /v1/query/ask`); the run checks the type of the loop's final query, its rows against the golden query and the judged results. The verdict shows the answer and the records it cites (`reply`). | | `POST /v1/evals/run/answers` | Checks your own agent's answers to the questions: one answer per golden, with the query that holds the answer and the route. The server runs each query and checks it as it checks Ask's answer, and the router's pick against the golden's route (`route_check`). See [Test your agent's answers](https://docs.littlebigbrain.com/guides/answer-questions/#test-your-agents-answers). | | `GET /v1/evals/results` | Eval runs, newest first. Takes `?limit=`, and `?runner=` for the runs of one agent. | | `GET`, `PUT /v1/evals/settings` | Reads or writes the graph's evals settings. | A trace starts when a `/v1/query/sparql-text` request carries a `request` field, or when `/v1/query/ask` answers from a query. Both are traces of a question, and every trace of one question shares one golden, whatever query answered it. A trace of `/v1/query/ask` also keeps the inputs of the request (`context`, `anchor`, `route`, `today`, `timeline`), so a run asks the same way, and the answer with its citations (`reply`). #### Schema and validation | Method & path | Purpose | | --- | --- | | `GET /v1/ontology` | The declared types, links and fields. Each class in `entity_type_defs` has a fixed `stable_id`, its query `iri` and its direct `super_types`. `?counts=true` adds exact `relation_defs[].edge_count` values. | | `POST /v1/ontology/define` | Puts the graph on an ontology document. Creates the graph when needed. See [Define your own ontology](#define-your-own-ontology). | | `POST /v1/ontology/evolve` | Applies ordered schema changes. `?dry_run=true` previews them. See [Evolve the schema](#evolve-the-schema). | | `GET /v1/schema` | The active ontology, the SHACL shapes as the validator parsed them, and the enforcement mode. | | `POST /v1/schema/publish` | Publishes SHACL shapes with `desired_mode`: `off`, `warn` or `reject`. | | `GET /v1/ontology/conformance` | The SHACL report of the latest published commit. | | `GET /v1/graph/schema-summary` | The schema observed in the latest published data. | | `POST /v1/ontology/drafts` | Builds a schema proposal from sample records. See [Ontology drafts](#ontology-drafts). | | `GET /v1/ontology/drafts?draft_id=` | Reads a draft. | | `POST /v1/ontology/drafts/validate?draft_id=` | Checks a draft again at its pinned commit. | | `POST /v1/ontology/drafts/promote?draft_id=` | Publishes a validated draft. Needs `Idempotency-Key`. | | `POST /v1/ontology/drafts/reject?draft_id=` | Records a rejection with a `reason`. The schema does not change. | | `GET /v1/ontology/starters` | Lists the starters (`crm`, `documents`, `work`) with each one's status on the graph. Works before the graph exists. See [Ontology starters](#ontology-starters). | | `GET /v1/ontology/starters/detail?starter=` | Reads one starter, its status and the operations applying it would run. | | `POST /v1/ontology/starters/apply` | Adds what the graph lacks of a starter in one ontology version. `dry_run: true` writes nothing. | | `POST /v1/ontology/starters/update` | Files what the graph lacks of a starter as one change suggestion. | | `POST /v1/ontology/suggestions` | Files a schema change suggestion, or updates the one with the same `key`. See [Ontology change suggestions](#ontology-change-suggestions). | | `GET /v1/ontology/suggestions` | Lists suggestions, newest first, with counts per status. | | `GET /v1/ontology/suggestions/detail?suggestion_id=` | Reads one suggestion with its evidence, dry run and discussion. | | `POST /v1/ontology/suggestions/validate?suggestion_id=` | Runs the dry run again against the current schema. | | `POST /v1/ontology/suggestions/accept?suggestion_id=` | Applies the change, or an edited change, to the current schema. | | `POST /v1/ontology/suggestions/dismiss?suggestion_id=` | Declines a suggestion with a `reason`. The schema does not change. | | `POST /v1/ontology/suggestions/supersede?suggestion_id=` | Withdraws a suggestion as its producer, with a `reason`. | | `POST /v1/ontology/suggestions/comment?suggestion_id=` | Adds a comment to the discussion. | | `GET /v1/ontology/fit-sources` | The graph's fit sources with their status: progress, watermark and lag, proposals kept and dropped, suggestions filed, this month's spend. `?name=` returns one. See [Suggest ontology changes from text](https://docs.littlebigbrain.com/guides/suggest-ontology-changes-from-text/). | | `PUT /v1/ontology/fit-sources` | Declares or changes the fit source of a class: its `class`, the `from` fields that hold the text, and a `context` sentence. | | `DELETE /v1/ontology/fit-sources?name=&confirm=` | Removes a fit source. Its suggestions stay. | | `POST /v1/ontology/fit-sources/preview` | Shows the text the fit would read. With `propose: true`, runs the models on up to three instances and returns the proposals without filing them. | | `POST /v1/ontology/fit-sources/refresh?name=` | Asks the fit job to run now and returns the status at once. | | `POST /v1/ontology/fit-text` | Reads one text once: pasted `text`, or the parsed pages of an uploaded file (`document`). Files the proposals as suggestions, or nothing with `dry_run: true`. | `POST /v1/schema/publish` never rewrites or checks existing data during the call. A new mode publishes the shapes again as a new version. The report follows in the background. Current data may break the new shapes. Then publish in `warn` first, and repair the data. Switch to `reject` once the report is clean. See [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). `GET /v1/ontology/conformance` reports `conforms`, `result_count`, `validated_at_seq`, `ontology_version`, and `shapes_version` when shapes are published. `?limit=` bounds the result rows (default 200, maximum 2,000). `conforms` and `result_count` count every result, even past the limit. `GET /v1/schema` lists the parsed shapes in `shapes`. Each has an `id`, its `targets`, a `path` for a property shape, and its `constraints`. The terms have the same form as the report. A result's `source_node_shape`, `result_path` and `component` find the rule that produced it. `?consistency=strong` asks for a report that covers the newest commit. Until one exists, it returns a retryable `429 strong_read_pending`. `GET /v1/graph/schema-summary` reads the latest published commit. It returns class populations and a bounded list of OWL and RDFS statements. Per predicate, it counts links in `resource_predicate_counts` and values in `literal_predicate_counts`. #### Publication and status | Method & path | Purpose | | --- | --- | | `GET /v1/graph/publication-status` | How far publication has come. Poll it to wait for a write. See [Publication status](#publication-status). | | `GET /v1/graph/activity` | The background work on the graph, for a status view. See [Graph activity](#graph-activity). | | `GET /v1/graph/read-snapshot` | What eventual reads serve now, and how far that is behind the newest commit. | | `GET /v1/graph/summary` | Graph counts at one published commit. | | `GET /v1/graph/metadata` | Storage diagnostics for the graph. | | `GET /v1/graph/index-upgrade` | The plan for optional index upgrades. See [Upgrade indexes on existing graphs](#upgrade-indexes-on-existing-graphs). | | `POST /v1/graph/index-upgrade` | Plans, or with `"dry_run": false` queues, an index upgrade. | | `GET /v1/graph/index-upgrade/job?job_id=` | Progress of an index upgrade. | | `POST /v1/graph/export/job` | Starts a full export as NDJSON. See [Export a graph](#export-a-graph). | | `GET /v1/graph/export/job?job_id=` | Progress and result of an export. | `GET /v1/graph/summary` returns class and link populations, `entity_count`, `current_edge_count` and `observation_count`. `relation_adjacency` counts the links per source type, link and target type, exactly. `?consistency=strong` returns a retryable `429 strong_read_pending` until publication covers the newest commit. `?min_indexed_seq=` sets a read floor, as in [Read consistency](#read-consistency). `GET /v1/graph/metadata` reports the newest commit, the schema version and storage sizes. Before the first publication it returns `200` with `index_caught_up: false` and no `snapshot.served_at_seq`. To wait for a write, use publication status. The default metadata read follows the graph head's RDF publication reference, validating its immutable root and mandatory F3 header. `snapshot.commit_seq` remains the newest observed head commit; `served_at_seq` and `published_lag_commits` describe that RDF base. A named but missing or damaged RDF artifact is an error, not first-publication pending. `index_caught_up` reports RDF commit coverage even with `include_indexes=false`; it does not prove class embeddings or model/configuration changes are ready. `include_indexes=true` opts in to deprecated BM25/ANN family lineage. Those legacy watermarks are separate from current class-declared embeddings and never determine RDF readiness. Missing legacy roots leave their fields empty; a damaged legacy root fails the explicit inspection. The default read does not consult the old family publication pointer. Each RDF root and F3-header diagnostic read has a 16 MiB control-object budget. A larger object returns `400 graph_metadata_budget`; this is a diagnostic limit, not an RDF manifest format limit. Ordinary RDF reads can remain valid. Use `GET /v1/graph/publication-status` for the publication workflow's readiness/progress readout where appropriate. #### Operator demand diagnostics Opt-in operator readouts are `GET /api/ops/read-demand` for the serving node and `GET /api/ops/read-demand/fleet` for the collecting runtime. Single mode uses its configured API token; SaaS requires the ops token. Environment and scoped graph tokens do not grant access. The local observer and fleet collector default off (`LBB_READ_DEMAND_ENABLED=false`, `LBB_SCHEDULER_READ_DEMAND=off`). The maintenance runtime exposes the authenticated fleet GET; ingest exposes neither readout. Fleet evidence is held in memory on the collector, so request it directly there. These diagnostics distinguish request completion from actual capacity release and report missing or partial evidence. They do not change replicas or establish serving readiness or a latency guarantee. ### Write records as JSON `POST /v1/graph/commit` writes links (`triplets`) and field values (`entity_properties`) in one commit. The graph must declare the types, links and fields first. See [Define your own ontology](#define-your-own-ontology). ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Idempotency-Key: support-4821-1" \ -H "Content-Type: application/json" \ -d '{ "triplets": [{ "source": {"type": "Ticket", "key": "4821", "name": "login fails after 3.1"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"}, "evidence": "Support triage, 2 Sep" }], "entity_properties": [{ "type": "Ticket", "key": "4821", "name": "login fails after 3.1", "properties": {"description": "Since 3.1, people are sent back to the login screen."} }] }' ``` The response reports `commit_seq`, the number of the new commit. It also lists the ids of the records it wrote. | Body field | Meaning | | --- | --- | | `triplets` | Links. Each has `source`, `relation` and `target`, and optional `evidence`, `confidence`, `valid_time` and `properties`. | | `entity_properties` | Field values. Each item names a record by `type` plus `key` or `name`, then gives `properties`. `remove_fields` drops named fields. `entity_id` addresses a record by its id. | | `entity_embeddings` | Vectors you supply for records. | | `property_merge` | `merge` (default) keeps the fields you do not send. `replace` makes the sent fields the record's whole set. | | `edge_idempotency` | `append` (default) records every link you send. `skip_unchanged` skips a link that is already current, for re-runnable backfills. | | `observed_at` | An RFC 3339 time. The commit is recorded as of that time, for backfills. | | `preconditions` | `expected_head_commit_seq`, `expected_ontology_version` or `expected_snapshot_token`. The commit fails with `409` if one does not match. | | `replace_owned` | The links and fields a writer owns on some records. The same commit removes the owned ones it does not write again. See [Owned outputs](#owned-outputs). | `properties` is a flat object, such as `{"description": "…"}`. The server converts each value to the field's declared type. A list of `{"field": …, "value": {"": …}}` items sets the wire type explicitly. `?dry_run=true` checks the commit and writes nothing. It needs no `Idempotency-Key`. A value of the wrong type fails the whole commit. So does an unknown field or an unknown type. **Record identity.** A record is its type plus its `key`. Without a `key`, it is its type plus its `name`. The record id is a hash of those values only, never of the fields. So `name` is a display label. To update a record, send the same `key` with the fields that changed. Field writes merge by default, so omitted fields stay. Two mistakes create a second record instead: - **A missing `key`.** A write that omits a keyed record's `key` resolves by `name`. It writes to a second record. Always send the `key`. - **A backfill without keys.** A backfill may know each record's `entity_id` (32 hex characters) and not its `key`. Send that `entity_id` on each `entity_properties` item. It skips resolution by type, key and name. `property_merge` changes which fields are kept. It never changes which record an item resolves to. #### Owned outputs A writer that derives facts from records, such as a [developer workflow](https://docs.littlebigbrain.com/guides/starter-workflows/#developer-workflows), writes them again when a record changes. Its old links and fields must go. A commit with `replace_owned` does both in one step: ```json { "entity_properties": [{ "type": "Note", "name": "Kickoff", "properties": { "word_count": 8 } }], "replace_owned": { "subjects": [{ "type": "Note", "name": "Kickoff" }], "properties": ["word_count"], "relations": ["MENTIONS"] } } ``` | Field | Meaning | | --- | --- | | `subjects` | The records this write is for. | | `relations` | Owned relations. Each current link of a subject with one of them that this commit does not write again is retracted. | | `properties` | Owned fields. Each one this commit does not set on a subject is removed. | | `retract_entities` | Records the writer made and no longer makes. Their current links and fields go, except the ones this commit writes again. | The removals and the new facts land in one commit. A rerun with the same output keeps the same facts (send `edge_idempotency: "skip_unchanged"` to record no new link events), and a rerun with less output removes the rest. The response reports `owned`: `edges_retracted`, `fields_removed` and `entities_retracted`. A commit may carry only `replace_owned`, for example to clear what a writer wrote for records that left its class. - Ownership is by relation and field. A retracted link is gone for every writer, so own relations that no other writer uses on the same records. - At most 1,000 records, 64 relations and 64 fields. An unknown type, relation or field is a `400`, as is a record that is both a subject and in `retract_entities`. - On an RDF-native graph the commit writes the statements a records graph shows for its facts, under the same IRIs. It removes what the same writer wrote before for each subject and no longer writes. The writer is named by its owned relations and fields. `entity_embeddings`, `preconditions` and `?dry_run=true` are refused there. A trigger message names records by IRI (`https://littlebigbrain.com/e/` and 32 hexadecimal characters). To name such a record in a commit, a subject or a link endpoint, set its `type` to its class and its `key` to `"\u0000lbb-eid:"` followed by those 32 characters: the commit addresses that record by its id, a keyed record included. The TypeScript SDK builds the reference with `entityRef(iri, type, name?)`, the Python SDK with `entity_ref(iri, type, name=None)`. A link endpoint or a field row writes its `name` as the record's display name, so pass the record's name there. ### Load RDF `POST /v1/graph/import/rdf` loads RDF documents. RDF is the W3C data model behind every graph; see [RDF under the hood](https://docs.littlebigbrain.com/start/concepts/#rdf-under-the-hood). The first import into a new graph creates it. [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/) walks through a full load. | Format | `Content-Type` | `?format=` | | --- | --- | --- | | N-Triples (the default) | `application/n-triples` | `ntriples`, `nt`, `n-triples` | | Turtle | `text/turtle` | `turtle`, `ttl` | | N-Quads | `application/n-quads` | `nquads`, `nq`, `n-quads` | | TriG | `application/trig` | `trig` | Save a first chunk as `chunk-1.nt`: ```text "Trail shoe" . . ``` Then import it into the `catalog` graph: ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/import/rdf?graph=catalog" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Idempotency-Key: catalog-chunk-1" \ -H "Content-Type: application/n-triples" \ --data-binary @chunk-1.nt ``` The response counts the triples read, imported and duplicated. It lists each predicate and reports `committed_commit_seq`. `published_generation` shows whether publication was queued. Small imports that reach one graph at the same time can share one commit. Each response then reports the shared `committed_commit_seq`. A read at that sequence sees the whole request. The import keeps each predicate IRI and each literal as written. SPARQL returns the original terms. The import does not declare a link type per predicate. Every statement is stored under one fixed relation, `RDF_TRIPLE`, which the response names. The console's Data view loads N-Triples and Turtle, including `.ttl` files. | Parameter | Default | Meaning | | --- | --- | --- | | `batch` | 100,000 | Triples per commit, up to 1,000,000. One request can span several commits, so a request as a whole is not atomic. | | `build` | `true` | `false` skips publication after this request. The server still publishes after 8 unpublished commits or 32 MiB of unpublished data, so later chunks stay writable. Leave the final chunk at `true`. | | `blank_node_scope` | none | A document id for blank-node labels. Up to 255 characters: letters, digits, `-`, `_`, `.`, `:` and `/`. | | `edge_idempotency` | `skip_unchanged` | Skips statements that are already stored, for re-runnable imports. `append` suits a one-time load that an `Idempotency-Key` protects. | | `strict` | `false` | `true` fails the whole request at the first bad statement. Otherwise bad statements are counted in `error_count`, with up to 100 examples. | | `base_iri` | none | The base for relative IRIs in Turtle and TriG. | | `observed_at` | now | An RFC 3339 time to record the commits at, for backfills. | | `graph_uri` | none | Always refused. See the named-graph rule below. | Four rules apply to a large load: - **Split on line boundaries.** N-Triples and N-Quads have one statement per line. A chunk must end at a line end. Each Turtle or TriG request must be a complete document. - **Keep each request at 64 MiB or less.** A larger body returns `400`. - **Duplicates are dropped.** A statement repeated within one request is stored once, across commit boundaries. The response counts `duplicate_resource_triples` and `duplicate_literal_triples`. - **Blank-node labels keep their identity.** In one document, a label names the same node across chunks and retries. Separate documents can reuse labels such as `_:b0`. Give each document its own `?blank_node_scope=`, on every chunk of that document. A graph serves the default RDF graph only, so the import refuses named graphs. In an N-Quads or TriG document, the import skips each named-graph statement and counts it in `error_count`. One `errors` entry names how many it skipped in each named graph. `?strict=true` fails the request at the first one. `?graph_uri=` returns `400` for the same reason. #### RDF-native graphs A graph whose first write is RDF is **RDF-native**. Write it with RDF import and SPARQL Update from then on. These routes return `400 rdf_native_graph` on it: - `POST /v1/graph/commit` without `replace_owned`, and `?dry_run=true` - `POST /v1/graph/import` - `POST /v1/graph/retract` - `POST /v1/graph/reload` - `POST /v1/graph/export/job`, which exports records. Read the statements with a SPARQL `CONSTRUCT` query on `/sparql` instead. `POST /v1/graph/import-jobs` accepts the upload, and the job then fails. Do not send NDJSON import jobs to an RDF-native graph. Reads and `POST /v1/ontology/evolve` work as usual. `GET /v1/status` reports the model as `graph_status.truth`: `property_graph` or `rdf_native`. The rule runs one way. A graph first written with `commit` or NDJSON accepts RDF import too. To mix records and statements in one graph, load the records first. #### Validation of RDF writes SHACL shapes are validation rules for the graph. A **focus node** is a record that a shape checks. In `reject` mode, RDF import and SPARQL Update check each write before it is applied. The check covers every focus node the new statements can affect. That includes a record that points at a changed value. A violation returns `400`, and the graph does not change. Two refusals belong to this check: - `shapes_not_write_enforceable`: the shape set has an `sh:sparql` constraint or a recursive shape reference. - `shacl_write_validation_budget`: one write affects more than 250,000 focus nodes. The limit on index lookups to find them is 2,000,000. Publish the shapes in `warn` mode for a load that meets either. Or split the write into smaller requests when the budget is the limit. `POST /v1/schema/publish` reports this before the first write. Its `write_enforcement` field lists each root shape and whether writes can be checked against it. It also names the constraint that prevents a check. On an RDF-native graph, a `reject` publication of such a shape set returns `400 shapes_not_write_enforceable`. The active shapes stay unchanged. ### Bulk NDJSON ingest For a large load of records, send newline-delimited JSON (NDJSON) to `POST /v1/graph/import-jobs`. Each line is one link or one record's fields. The shapes match the items of `triplets` and `entity_properties` in a commit. Save the lines as `records.ndjson`: ```json {"source": {"type": "Ticket", "key": "4823", "name": "CSV export times out"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "DATA-58", "name": "export runs on one thread"}, "evidence": "Support triage, 4 Sep"} {"type": "Ticket", "key": "4823", "name": "CSV export times out", "properties": {"description": "Exports with more than 10,000 rows stop after 30 seconds."}} ``` Submit the job, then poll it with the `job_id` from the response: ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/import-jobs?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Idempotency-Key: support-import-1" \ -H "Content-Type: application/x-ndjson" \ --data-binary @records.ndjson curl --fail-with-body -sS "$LBB_URL/v1/graph/import-jobs?graph=support&job_id=" \ -H "Authorization: Bearer $LBB_API_KEY" ``` The server checks the graph incarnation before storing the upload. A background worker then commits it in groups and saves a checkpoint after each group. A never-created graph can be created by the job. A deleted graph returns `409 graph_epoch_changed` at submission, before an upload or job is stored. To reset an existing graph, wait for `POST /v1/graph/delete?graph=support&confirm=support` to succeed, call `POST /v1/graph/create?graph=support`, install its schema, then submit with a new `Idempotency-Key`. New jobs remain bound to the incarnation accepted at submission, including on retries. A deletion racing submission can still stop an accepted job; it cannot redirect that job into a recreated graph. Reusing an old key on a new incarnation returns `409 idempotency_conflict`. Inspect an old job with GET instead of resubmitting it. Legacy jobs without a stored incarnation refuse recreated graphs; their POST replay can also conflict after upgrade. - **States.** `queued`, `running`, `cancellation_requested`, `cancelled`, `succeeded` or `failed`. - **Progress.** `progress.stage` is `reading_payload`, `committing_group`, `persisting_checkpoint`, `enqueueing_publication` or `completed`. While a group commits, progress also reports its index, record count and bytes. - **Groups.** A group holds at most 32 MiB and up to `?batch=` records. The batch is 1 to 10,000, and 10,000 by default. One line can be up to 32 MiB. - **Options.** `?strict=true` stops at the first bad line. `?observed_at=` records every commit at an RFC 3339 time. - **Failure.** A failed job reports `failure` with `code`, `message`, `retryable`, `stage`, `attempts` and an opaque `diagnostic_id`. A storage or network failure can use up its retries. It is then reported as retryable, never as bad input. - **Cancel.** `DELETE` with the `job_id` asks the job to stop. - **Retention.** A job's record stays readable for 14 days after it ends. Each group is checked against the current graph for duplicates, link cardinality and SHACL shapes. A succeeded job reports `committed_commit_seq`. Publication follows in the background. To wait for it, poll `GET /v1/graph/publication-status`. Stop when `state` is `current` and `published_seq` is at least that commit. Wait once, after the final job. `POST /v1/graph/import` runs the same commit loop inside the request, for bodies up to 64 MiB. It commits `?batch=` records at a time (default 1,000, maximum 10,000). It takes `strict` and `observed_at`, and an optional `Idempotency-Key`. This synchronous route retains its existing automatic creation/recreation behavior, as do synchronous commit and reload. Use the explicit create-and-submit sequence above when switching to durable jobs. Both NDJSON routes refuse an RDF-native graph. See [RDF-native graphs](#rdf-native-graphs). ### Read one record For full RDF IRIs, exact literal forms and independent claim metadata, use the additive [`records/1` read](https://docs.littlebigbrain.com/guides/records/): `GET /v1/graph/entity?version=records/1&iri=…`. The same read is `client.entities.retrieve` in TypeScript/Python and `lbb_inspect action=entity iri=…` in MCP. The default native entity view below stays unchanged. `GET /v1/graph/entity` reads one record by `id`, by `type` and `key`, or by `type` and `name`. It reads the same data as SPARQL. ```sh curl --fail-with-body -sS "$LBB_URL/v1/graph/entity?graph=support&type=Ticket&key=4821" \ -H "Authorization: Bearer $LBB_API_KEY" ``` - **Fields.** `attributes` holds the typed field values. - **Links.** `rdf_relations.outgoing` and `rdf_relations.incoming` hold the current links. Each has a `relation`, the linked `entity`, the link's `properties` and its `evidence`. - **Link properties.** `properties` holds the link's own field values, typed like `attributes`. A link without properties leaves it out. In `append` mode a link can be stated more than once. `properties` shows the newest statement, and SPARQL returns all of them. The response's `edge_properties` is `complete` or `partial`, by the same rules as `evidence` below. - **Link cap.** `?edges=` caps each direction: default 1,000, range 1 to 10,000. A capped direction carries `outgoing_truncation` or `incoming_truncation`, with `returned` and a lower bound `total_observed`. - **Evidence.** Each link lists its evidence, newest commit first: `text`, `source_id`, `observation_id`, `confidence`, `commit_seq`, and a `region` when the evidence has one. `?evidence=` sets how many entries per link: default 5, range 0 to 20. A link with more carries `evidence_truncated`. The response's `evidence` is `complete` or `partial`. On a `partial` read, some links were written before the platform kept evidence in the graph, and an empty list does not prove a link has none. A graph created before then reads as `partial` until the platform rebuilds its evidence; after the rebuild, reads at or after its commit are `complete` and older links show their evidence. Ask support to rebuild a graph. - **Consistency.** Eventual is the default. `?consistency=strong` reads your own write at once. `?as_of_commit_seq=` reads a retained commit. `snapshot.served_at_seq` names the commit read. - **Valid time.** `?as_of=` returns `400`. Use `as_of_commit_seq`. `unavailable_sections` lists the sections this read leaves empty: `metadata_provenance`, `current_state`, `history` and `observations`. It also lists `outgoing` and `incoming` when the evidence is `partial`. The links are in `rdf_relations`. `metadata.object_kind` is `unavailable`. A link with properties and evidence looks like this: ```json { "relation": { "id": 3, "name": "REPORTED_AS" }, "entity": { "id": "7d02…", "type": "Bug", "name": "session token expiry" }, "properties": { "status": "confirmed" }, "evidence": [ { "text": "Support triage, 2 Sep", "source_id": "inline:fixture-1:0", "observation_id": "3e6f0c…", "confidence": 1.0, "commit_seq": 12 } ] } ``` Nulls, bytes, vectors, non-finite numbers and empty sets have no RDF form. `attributes` leaves them out. To read an RDF resource that is not a record, use SPARQL. ### Read consistency After a commit, the platform publishes it for queries and search. This usually takes a second or two. Reads take a `consistency` option: - `eventual` is the default. It reads the latest published commit. It is fast, and it can be a little behind the newest write. - `strong` also applies the newest writes. Use it to read your own write at once. To wait for one write, pass its `commit_seq` as `min_indexed_seq`. The read then covers that commit or a later one. | Route | How to set it | | --- | --- | | `/sparql`, `/v1/query/sparql-text` | `?consistency=` and `?min_indexed_seq=` on the URL. A floor without `consistency` reads as `strong`. | | `/v1/query/sparql` | `consistency` and `min_indexed_seq` in the JSON body. | | `/v1/graph/entity` | `?consistency=` | | `/v1/graph/summary` | `?consistency=` and `?min_indexed_seq=` | | `/v1/ontology/conformance` | `?consistency=` | An unknown `consistency` value returns `400`. **Responses.** An eventual response has a `snapshot` block. `snapshot.served_at_seq` is the commit it read. Some routes also set `stale: true` when that commit is older than the newest one. A strong `/v1/query/sparql-text` response has no `snapshot` block. **Strong SPARQL.** A strong read through `/sparql` or `/v1/query/sparql-text` serves the newest commit at once. It does not wait for publication. It never returns an older answer as a success. **Retries.** Three `429` codes mean the read must wait. Retry the same request after `Retry-After`: | `code` | When | | --- | --- | | `read_your_writes_pending` | The floor in `min_indexed_seq` (or `min_commit_seq` on `/v1/query/names`) is not covered yet. A floor above the newest commit also gets it, until a commit reaches it. | | `strong_read_pending` | A strong read on the summary, conformance or structured-query route waits for publication to cover the newest commit. | | `ingest_busy` | An eventual read on a graph that has nothing published yet. | `curl --retry 10` retries a `429` and honors `Retry-After`. Poll `/v1/graph/publication-status` only when a workflow needs publication itself. An example is a search right after a write. See [Publication status](#publication-status). ### Time travel A read can name a past commit with `as_of_commit_seq`. The answer is then the graph as it was at that commit. It stays the same while the commit is retained. See [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/). | Route | Where to pass it | | --- | --- | | `/sparql` | `?as_of_commit_seq=` | | `/v1/query/sparql-text` | `as_of_commit_seq` in the body | | `/v1/query/sparql` | `as_of_commit_seq` in the body. It keeps MCP structured cursor pages on one commit. | | `/v1/graph/entity` | `?as_of_commit_seq=` | | `POST /v1/graph/export/job` | `?as_of_commit_seq=` | ```sh curl --fail-with-body -sS -G "$LBB_URL/sparql?graph=support&as_of_commit_seq=1" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Accept: application/sparql-results+json" \ --data-urlencode 'query=SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }' ``` - **A commit after the newest one** returns `400 invalid_request`. - **A commit that is not retained** returns `400 historical_generation_unavailable`, with `param` set to `as_of_commit_seq`. A retry does not help. The server never serves a nearby commit instead. - **Valid time.** SPARQL does not rebuild the graph at a point in world time. `?as_of_valid_time=` returns `historical_generation_unavailable`. Only `as_of_commit_seq` pins a read. ### Current status diagnostics With a matching server and SDK build, request `GET /v1/status?version=status%2F1` for a typed `status/1` response. In TypeScript use `client.status({version: "status/1"})`; in Python use `client.status(version="status/1")` or typed `client.status_model()` (also awaitable on the async client). These helpers refuse an older server's legacy 200 response instead of treating an ignored selector as current diagnostics. `graph_status.rdf.publication_caught_up` describes RDF publication coverage. `graph_status.class_embeddings` separately reports graph model configuration, recipe count, and each inspected serving/building version and watermark. `ok` means the status request completed; `complete` means diagnostic coverage, not readiness. Unknown counts are `null`. A missing class roster is unknown; a valid empty roster proves zero recipes. The read inspects at most 16 recipes and 16 MiB of class control-object metadata; a larger or unreadable inventory remains partial or unavailable. RDF control objects have a separate 16 MiB per-object diagnostic budget. These budgets do not limit ordinary RDF reads, embedding listing or search. Status calls no model, performs no graph/configuration/job writes and never consults legacy BM25/ANN roots. The components share one graph incarnation but are not an atomic snapshot across concurrent changes. The prepared `2026-10-27` source defaults to `status/1`. Explicit `version=legacy` is compatibility only and lasts exactly one released unified API contract generation and is refused in its immediate successor. Release identities and migration receipts remain a release gate; this source addition is not a deployed change. Existing SDK `status()` calls remain unversioned/raw: they follow the server default, which can differ on an older server. Explicit `status/1` is checked. ### Publication status A commit is durable once it is acknowledged. Publication then makes it the latest published data, which eventual reads and search use. `GET /v1/graph/publication-status` reports how far publication has come. It answers before the first publication too. A graph that is already `current` verifies its own head, desired target and published manifest without reading unrelated active jobs. Other states retain the job progress and blocked-work diagnostics below. ```sh curl --fail-with-body -sS "$LBB_URL/v1/graph/publication-status?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" ``` | Field | Meaning | | --- | --- | | `state` | `current`, `queued`, `compacting`, `building`, `publishing` or `blocked`. | | `head_seq` | The newest commit. | | `target_seq` | The commit that publication works toward. | | `published_seq` | The newest published commit. | | `lag_commits` | The number of commits not yet published. | | `current_stage` | The step in progress, when one runs. | | `last_progress_at_micros` | When publication last made progress. | | `retry` | `retry_after_ms`, `eventual_read_available` and a `message`. | | `epoch` | Changes when the graph is deleted and created again. | To wait for a write, poll until `state` is `current`. `published_seq` must be at least the write's `commit_seq`. Stop on `blocked` and read `retry.message`. The SDKs do this in `waitForPublished` and `wait_for_published`. Strong reads stay exact in every state. Eventual reads serve the older published commit until publication is current. `GET /v1/graph/read-snapshot` shows what eventual reads serve now. It needs a first publication. | Field | Meaning | | --- | --- | | `snapshot.served_at_seq` | The commit eventual reads serve. | | `head_seq` | The newest commit. | | `query_lag_commits` | Commits that eventual reads do not cover yet. | | `base_current` | `true` when the served data matches the newest commit, including schema, shape and index changes. | | `conformance_lag_commits` | Commits that the SHACL report does not cover yet. | ### Graph activity `GET /v1/graph/activity` lists the background work on one graph. A status view can poll it every few seconds. `GET /version` advertises it as `graph_activity_v1`. ```sh curl --fail-with-body -sS "$LBB_URL/v1/graph/activity?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" ``` | Field | Meaning | | --- | --- | | `idle` | `true` when no job is queued, waiting or running, and publication and embeddings are current. A failed embedding also counts as idle: no work runs until it is fixed, and its `error` names the cause. | | `head_seq` | The newest commit. | | `published_seq` | The newest published commit. | | `lag_commits` | The number of commits not yet published. | | `publication` | The `state` of [Publication status](#publication-status). | | `write_limit` | The commits not yet published (`pending_commits`, `pending_records`, `pending_bytes`) and the limits on them. Past a `soft_pending_*` limit (256 commits, 4 M records, 1 GiB) each write waits a little, up to 1 s near the hard limit. At a `max_pending_*` limit (1,024 commits, 64 M records, 8 GiB) the server refuses new writes with a retryable `429 ingest_busy` until the index catches up. | | `embeddings` | One entry per embedding: `state`, `lag_commits`, `progress` and `error`. | | `items` | Up to 25 jobs. Queued, waiting and running jobs come first, newest first. | An item is one job. `kind` names the work: `publish`, `compaction`, `statistics`, `validation`, `embeddings`, `import`, `export`, `fork` or `index_upgrade`. `state` is `queued`, `waiting`, `running`, `succeeded`, `failed` or `cancelled`. `stage` names the step in progress. For a waiting job, it names the condition. `progress` has `done`, an optional `total` and a `unit`. The units are `phases`, `partitions`, `lines`, `bytes`, `entities`, `commits` and `tables`. A failed job has an `error` with a stable `code` and a short `message`. A job that waits for a retry, or runs the try after a failed one, has one too, with `retryable: true`. Imports and embeddings have codes of their own. Other jobs use these: | `code` | `message` | | --- | --- | | `storage_unavailable` | A request to object storage failed. | | `write_conflict` | Another update changed the graph first. | | `storage_data_error` | Stored data did not pass an integrity check. | | `data_missing` | Stored data the job needs is missing. | | `server_busy` | The server was busy with other work. | | `job_failed` | An internal error stopped the job. | For other detail, give the item `id` to support. An embedding is `ready`, `backfilling`, `behind` or `failed`. While it backfills, `progress.done` counts the instances it read. One call reads at most 30 objects and lists nothing. Each server keeps the jobs that finished for five minutes. A poll of an idle graph reads about five objects. ### Back-pressure Two kinds of busy response exist. Both are `429` with `Retry-After`. Both are safe to retry with the same idempotent request. Retry on the HTTP status and `Retry-After`; the `code` names the kind. - **`429 server_busy`: this server is briefly busy.** It is short of memory, or of room for request bodies. That room is 128 MiB by default. Or the server that owns this graph's writes is too busy to take one. A refused write did not run. - **Large queries.** A query too large for the server's free memory also gets `server_busy`. Queries that are already prepared are not affected. The same query succeeds once memory frees up. - **`429 ingest_busy`: this graph's writes are catching up.** No server can accept the write until they drain. One cause is a full queue of unpublished writes. Others are many writers on the same graph at once, or a large rebuild in progress. - **Slower writes come first.** As unpublished writes pile up, each new write waits a little before it commits. The wait grows with the queue. Strong reads still see every write; they get slower, not refused. - **One import over many commits.** Prefer one streamed import over many concurrent single-record commits. Writes never block reads. `400 scan_too_wide` is not pressure. The query's plan needs more reads than one request may make. The same query fails the same way every time, so a retry does not help. To fix it, narrow the pattern. Bind a subject or a class, or split the query. A `LIMIT` helps when the engine can apply it early. `400 result_too_large` means the result is larger than one response may hold. It also means one join step inside the query built too many rows, often a pattern that shares no variable with the rest. A successful write can carry an `X-LBB-Throttle-Ms` header and `throttle_ms` and `drain_pressure` fields. They appear when the write waited in a queue. Lower your write concurrency as they rise, to avoid `server_busy` and `ingest_busy`. ### Define your own ontology An ontology is the set of types, links and fields a graph declares. `POST /v1/ontology/define` imports an ontology document and puts the graph on it. Writes are then checked against your types and links. | Body field | Meaning | | --- | --- | | `source` | The document text. | | `format` | `auto` (default) detects the format. Also `spec`, `lbb_json`, `json_ld`, `turtle`, `rdf_xml`, `csv` and `tsv`. | | `merge_default` | `true` keeps the built-in vocabulary underneath your types. | | `dry_run` | `true` previews the result. It creates no graph and changes nothing. `?dry_run=true` does the same. | This call declares the quickstart's schema: ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/define?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"format": "spec", "merge_default": false, "source": "{\"entity_types\": [\"Ticket\", \"Bug\", \"Release\"], \"relation_types\": [{\"name\": \"REPORTED_AS\", \"source\": \"Ticket\", \"target\": \"Bug\"}, {\"name\": \"FIXED_IN\", \"source\": \"Bug\", \"target\": \"Release\"}]}"}' ``` The call is safe to repeat. The script that creates a graph and defines its schema can run on every deploy. On an existing graph, the document is compared with the active ontology. The response reports what happened: - `graph_created` is true only when this call created the graph. - `changed` is true only when a new ontology version was written. A call that applied something never answers `changed: false`. | Difference from the active ontology | Result | | --- | --- | | none | `200`, `changed: false`. Nothing is written and `ontology_version` stays the same | | additive: new entity types, new direct `rdfs:subClassOf` parent links, new relations, renames that keep an existing type's identity, a wider relation domain or range, a new property field | `200`, `changed: true`, with the applied entries in `changes`. Stored records keep resolving, because every existing type and relation keeps its identity | | a relation's declared domain or range is narrower | `400`, code `ontology_restrictive_change`. The message lists the narrowed relations. Confirm a narrowing with `POST /v1/schema/publish` and `confirm_restrictive: true`, or apply it one operation at a time with `POST /v1/ontology/evolve` and `narrow_relation`, which reports the records it would affect before publishing | | an entity type or relation the graph still defines is missing | `400`, code `ontology_identity_breaking_change`. The message lists the dropped subjects. Stored records resolve through those definitions, so define keeps them. Publish an ontology that keeps them (`merge_default: true` keeps the built-in vocabulary underneath your own), use a different graph id, or delete the graph with `POST /v1/graph/delete?confirm=` and define it again | | a stated change no additive operation expresses: a property field the graph declares given a new type or a new allowed-value set, or a declared relation domain or range opened up to any type | `400`, code `ontology_unsupported_change`. The message lists the subjects. Apply them with `POST /v1/ontology/evolve`, which names each change as its own operation | Every refusal writes nothing. A rejected call leaves the active ontology as it was. Two things in a document are read as absence, and the graph keeps them: - **An omitted field.** A property field the graph holds stays, because no operation removes a field. Fields enter a document through the `lbb_json` format or through RDF data properties. - **A different `required` flag.** A `required` flag that disagrees on an existing field is left alone. It is advisory, and no check acts on it. An `owl:DatatypeProperty` imports as a property field with the matching value type, never as a relation. So does an untyped property whose every `rdfs:range` is a datatype. #### Evolve the schema `POST /v1/ontology/evolve` grows a live schema one operation at a time. The body is `{"ops": [...]}`. Operations apply in order, so a type added early is visible to a later operation. `?dry_run=true` previews the result and the records each change would affect. ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"ops": [{"op": "add_property", "name": "description", "value_type": "text"}]}' ``` | `op` | Fields | Effect | | --- | --- | --- | | `add_entity_type` | `name` | Declares a new type. | | `add_super_types` | `entity_type`, `super_types` | Adds direct parent types. Each link is stored as an `rdfs:subClassOf` statement, which `entailment` reads. Declare new parents earlier in the same request. Repeating a link does nothing. Removing a parent is not additive. | | `add_relation` | `name`, `domain`, `range`, optional `cardinality`, `inverse_name`, `transitive`, `symmetric`, `temporal_semantics`, `reducer` | Declares a new link type. | | `widen_relation` | `relation`, `add_domain`, `add_range` | Adds existing types to a link's allowed sources or targets. | | `add_property` | `name`, `value_type`, `required`, `allowed_values`, `unit` | Declares a field. `value_type` is `text` (default), `keyword`, `bool`, `i64`, `f64`, `date_time` or `bytes`. `allowed_values` is enforced on write for `keyword`, `text`, `i64` and `bool`. `unit` names the unit of an `i64` or `f64` field, such as `mm`, `cm`, `kg` or `m2`. | | `set_property_constraint` | `property`, `allowed_values` | Sets or clears the allowed values of an existing field. Stored values are not checked again. | | `set_property_unit` | `property`, `unit` | Sets the unit of an existing `i64` or `f64` field. Without `unit`, it clears it. Stored values are not converted. | | `rename_entity_type`, `rename_relation` | `from`, `to` | Renames. Stored records keep resolving to the new name. | | `set_relation_inverse` | `relation`, `inverse_name` | Sets a second name for reading a link in reverse. | | `set_relation_cardinality` | `relation`, `cardinality` | `one_to_one`, `one_to_many`, `many_to_one` or `many_to_many`. A narrowing is checked against current links first. | | `narrow_relation` | `relation`, `remove_domain`, `remove_range` | Removes allowed sources or targets. Blocked while current links use them. | | `remove_entity_type`, `remove_relation` | `name` | Refuses new writes of the type or link. Stored data stays readable. Blocked while current data uses it. | Each operation accepts only the fields in its row. An unknown or misspelled field answers `400` with code `invalid_request`. The message names the field, and nothing is written. A blocked change reports each conflict with its `kind`, the exact `affected` count and sample ids. Migrate or retract the affected data, then apply the change. ### Ontology drafts A draft proposes a schema for a new data source. Its records need not be in the graph yet. `POST /v1/ontology/drafts` takes 1 to 100 sample records and never imports them. The draft stores evidence references and proposed additive operations. It analyzes your competency questions, with query outlines. It also reports coverage, pitfalls and a confidence. It is pinned to one commit and one ontology version. Check it with `/validate` at that pin. Then promote it with an `Idempotency-Key`, or reject it with a `reason`. A stale draft fails validation. The server does not move it to a newer commit. ### Ontology starters A starter is a versioned base ontology for a domain: `crm`, `documents` or `work`. Apply one to start a graph from it. The [Ontology starters guide](https://docs.littlebigbrain.com/guides/ontology-starters/) lists their classes. ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/starters/apply?graph=sales" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ -d '{ "starter": "crm" }' ``` Apply adds what the graph lacks in one ontology version. A relation the graph already has gains the starter's classes. Applying again answers `no_op: true`. `dry_run: true` answers the same without writing. `expected_ontology_version` refuses with `409 conflict` when the graph moved. A graph can hold a term differently: a property with another value type, a relation with another cardinality or inverse, or a class with another parent. Apply then refuses with `409 starter_conflict` and lists the terms in `error.details.conflicts`. Nothing is written. `GET /v1/ontology/starters` and `/detail` answer for a graph that does not exist yet: every starter is `absent` and `ontology_version` is `null`. Apply and update need the graph and answer `404 graph_not_found` without it. `POST /v1/ontology/starters/update` with `{ "starter": "crm" }` files what a `partial` graph lacks as one change suggestion, keyed `starter:crm/1.0.0`. Asking again returns the same `suggestion_id`. An `absent` starter answers `409 starter_not_applied`. ### Ontology change suggestions A suggestion proposes a schema change for a person to review. Integrations, agents and people file the same object. It holds a title, a rationale, the evolve operations, and evidence such as a record count. `POST /v1/ontology/suggestions` files it and stores a dry run of the change. Filing never changes the schema. The same `key` updates the same suggestion, so a producer can file again safely. ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/suggestions" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ -d '{ "key": "hubspot-main/deals", "title": "Add class Deal", "origin": { "kind": "integration", "id": "hubspot-main" }, "change": [{ "op": "add_entity_type", "name": "Deal" }] }' ``` A person then accepts, dismisses or comments on it. Accept applies the change to the schema as it is now. The suggestion is not pinned to a commit. Pass `change` in the accept body to apply an edited change. A change that conflicts with current data returns `400`, and the schema does not change. A suggestion can also hold `facts`: links and field values in the shapes of `POST /v1/graph/commit`, with an optional `replace_owned` and a `summary`. Accepting commits them after the change, and the suggestion records the commit in `facts_commit`. `change` may be empty when `facts` is set. See [Ontology suggestions](https://docs.littlebigbrain.com/guides/ontology-suggestions/#facts). ### Export a graph `POST /v1/graph/export/job` starts a durable export of the whole graph as NDJSON. It keeps every record, link and field. A link gets one line per piece of evidence it carries, so every piece comes back when you import the file into a new graph with `POST /v1/graph/import`. Importing the same file again adds nothing. Poll `GET /v1/graph/export/job?job_id=` until it completes. The job writes zstd-compressed NDJSON parts and a `manifest` to your stack's storage. Its progress reports the parts with their bytes and lines, and the exported commit. `?as_of_commit_seq=` exports a past commit. There is no synchronous whole-graph export. For an RDF document of the current data, run this query against `/sparql`: ```sparql CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } ``` `Accept: application/n-triples` returns N-Triples. Any other `Accept` returns Turtle. The result is the default RDF graph, the only one a graph serves. Bound it with `LIMIT` and `OFFSET`, and use the export job for a whole graph. ### Native SPARQL 1.1 Protocol Your stack serves the SPARQL 1.1 Protocol at `/sparql`. Standard clients, such as YASGUI, Protégé and RDFLib's `SPARQLWrapper`, connect directly. - `GET /sparql?query=` - `POST /sparql` with a form body (`query=`), or with an `application/sparql-query` body - SELECT and ASK results: SPARQL Results JSON (the default), XML, CSV or TSV. Choose with `Accept` or `?format=json|xml|csv|tsv`. - CONSTRUCT and DESCRIBE results: Turtle (the default) or N-Triples. Choose with `Accept: application/n-triples` or `?format=nt|ttl`. ```sh curl --fail-with-body -sS -G "$LBB_URL/sparql?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Accept: application/sparql-results+json" \ --data-urlencode 'query=SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5' ``` Types, links and fields you declare have fixed IRIs: `https://littlebigbrain.com/class/`, `https://littlebigbrain.com/r/` and `https://littlebigbrain.com/p/`. The names are **lowercase**. Records written as JSON get IRIs under `https://littlebigbrain.com/e/`. IRIs loaded through RDF import keep their original form. [Core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql) maps the names. The [TypeScript](https://docs.littlebigbrain.com/sdks/typescript/#sparql) and [Python](https://docs.littlebigbrain.com/sdks/python/#sparql) SDKs return parsed rows. You do not match `head.vars` to bindings by hand. `/sparql` takes these extension parameters: | Parameter | Meaning | | --- | --- | | `consistency` | `eventual` (default) or `strong`. See [Read consistency](#read-consistency). An unknown value returns `400`. | | `min_indexed_seq` | A read floor: the read covers this commit or a later one. Without `consistency`, the read is `strong`. | | `as_of_commit_seq` | Reads a past commit. See [Time travel](#time-travel). | | `entailment` | `none` (default) returns the facts as written. `subclass`, `rdfs` or `owl` adds facts that follow from the schema of the commit read. An unknown value returns `400`. See [Reasoning](https://docs.littlebigbrain.com/guides/reasoning/). | | `reason` | `true` returns an error. Stored rules already ran when the data was published. | | `max_graph_triples` | Lowers the CONSTRUCT and DESCRIBE limit for this request. | CONSTRUCT and DESCRIBE return at most 1,000,000 triples by default. The server operator sets this limit with `--sparql-max-graph-triples` or `LBB_SPARQL_MAX_GRAPH_TRIPLES`. `max_graph_triples` can only lower the limit. A value above it returns `400` with the server's limit. A result over the limit returns `400`. The check runs before any byte is sent, so a `200` graph response is always complete. Multi-hop questions use SPARQL 1.1 property paths through `/sparql` or `/v1/query/sparql-text`. Predicate, inverse, sequence, alternative and closure paths are supported. They read one commit and apply the same filters as other patterns. There is no separate traversal route. Record reads stay one hop. ### SPARQL Update and graph documents Send an `application/sparql-update` body to `/update` or `/v1/update`. Only `INSERT DATA` is accepted. One request becomes one commit. Other update forms return `400`: `DELETE`, `WHERE`, `CLEAR`, `DROP`, `COPY`, `MOVE`, `ADD`, `CREATE` and `LOAD`. ```sh curl --fail-with-body -sS "$LBB_URL/update?graph=catalog" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Content-Type: application/sparql-update" \ -H "Idempotency-Key: catalog-v1" \ --data-binary 'INSERT DATA { "Road shoe" }' ``` A successful update returns `204` with no body. An `Idempotency-Key` makes a retry safe, and a malformed key is rejected. `/sparql` also accepts an `update=` form field, with the same 64 MiB limit. A form with both `query=` and `update=` returns `400`. `POST /rdf-graph-store?default` appends a document to the graph. It takes `text/turtle` or `application/n-triples`. ```sh curl --fail-with-body -sS -X POST "$LBB_URL/rdf-graph-store?default&graph_id=catalog" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Content-Type: text/turtle" \ --data-raw '@prefix rdfs: . rdfs:label "Trail sock" .' ``` On this route, `?graph=` names an RDF graph by IRI, as the Graph Store Protocol defines. Use `?graph_id=` to select the little big brain graph. - `?graph=` returns `400`, because a graph serves the default RDF graph only. - A request without `?default` or `?graph=` returns `400`. - `PUT` and `DELETE` return `400`, and there is no `GET`. Read with SPARQL and retract with the graph write routes. ### Upgrade indexes on existing graphs A server update can add optional indexes that make some queries faster. A graph that receives no writes does not get them by itself. The index upgrade routes build them for one graph. They never change how your data is stored. 1. **Plan.** `GET /v1/graph/index-upgrade?graph=support` returns the plan for `target=rdf-value-order-v3`. It lists the tables, the `missing_tables` and `outdated_tables`, and `max_additional_bytes`. 2. **Queue.** `POST` the same route with `{"dry_run": false, "target": "rdf-value-order-v3"}`. Without `"dry_run": false`, the POST only plans. 3. **Follow.** Poll `GET /v1/graph/index-upgrade/job?job_id=` in the same graph. Follow `progress.next_job_id` until it is absent. Then check whether the last `outcome` is `complete` or `partial`, and read the plan again. Queries keep running during the build. The new indexes take effect together when the build publishes. A query that a new index cannot serve runs exactly as before. A build can skip a table that exceeds its size or memory limits. The result then reports partial coverage. To retry after a failure or a partial result, send a new `retry_id`. The switch to new indexes can expire SPARQL page cursors. On `410`, restart the listing. Upgrade one graph first, and check its results and query speed. Then upgrade the rest. ### Operator serving preparation `POST /api/ops/serving-readiness?graph=&epoch=` prepares the exact current published RDF source on the contacted serving node. It is disabled by default (`LBB_SERVING_READINESS_ENABLED=true` opts in); SaaS callers need the operator token and `storage_tenant_id`, while single mode uses its configured API token. Environment keys and scoped graph tokens do not grant this SaaS operator operation. Maintenance and ingest runtimes refuse it. Preparation reads the graph directly, verifies a bounded set of immutable ranges into the local cache, and checks source identity again. The response includes source/epoch, node/boot/storage identity, warm coverage and currently available execution slots. `prepared`, `partial`, `stale` and `unknown` are instantaneous observations; `action_eligible` is always false. The default preparation does not reserve capacity or cache residency. Later reads retain ordinary snapshot pins. The default and maximum call budget is 64 MiB and 10 seconds, lowered with `max_bytes` and `max_millis`. The plan is independently limited to 4,096 extents and 128 table entries. Repeated calls can warm a larger plan; plans exceeding these bounds remain partial. An optional `source_digest` pins the source from a previous response. Reference graphs, history, search, placement authority, failure headroom, general query latency and automatic scaling are outside this profile. No durable source or control records are changed. The same route supports `operation=reserve|revalidate|release`. Reserve requires the exact source digest, configured node/cell identity, fresh native placement, and a finite admission cap. It holds one real execution slot for at most ten seconds; revalidation cannot renew it. No token can route a query, and cache residency and snapshot pins are not leased. All results remain `action_eligible=false`. SaaS callers still require the operator token. `POST /api/ops/serving-capacity` reviews up to four explicit local reservations and ephemeral min/desired/max node intents with failure headroom. It requires a configured operator credential, the same default-off readiness gate, and at most 16 KiB of JSON; its total budget is ten seconds. It revalidates local holds and reports deficits, but the current best-effort inventory cannot certify fleet completeness. It therefore offers no executable scaling or NVMe proposal. It never scans all environments, persists intent, changes placement or provisions nodes. These tools are preparation evidence; automatic capacity control remains unfinished. ### Current RDF demo checks On a matching unreleased server/client build in local single mode, `POST /v1/demo/run?version=demo%2F1&dataset=semantic_graph` checks three exact RDF relationships in the checked-in fixture. It requires the existing enabled demo route; SaaS and disabled servers retain their refusal. Loading data and waiting for RDF publication are separate steps. The prepared `2026-10-27` source also selects this contract when query version is omitted. Only explicit `version=legacy` retains the old demo for the first released unified generation; its successor refuses it. Source preparation does not establish a deployed or elapsed release window. TypeScript `client.demoRun()` and Python `client.demo_run()` / typed `client.demo_run_model()` explicitly send `version=demo/1` and validate the response (also awaitable in Python). An older server's ignored selector and legacy 200 response are refused. The guards reject empty/partial success, inconsistent counts/outcomes, mixed epoch/commit pins and wrong exact terms. `passed` requires all three cases at one published commit. Missing, pending, unported, malformed, timed-out and partial cases cannot pass. `complete` means all cases produced interpretable answers, so a fully observed wrong answer is complete but failed. Unknown publication/case-definition fields are `null`. Expected answers are full IRI terms; actual evidence is the exact reported SPARQL-results JSON string, preserving lexical RDF values. The current run calls no provider, changes no durable graph/configuration state, enqueues no work and reads no legacy search root. It reports class embedding checks as `not_requested`. Its bounds are three queries, two rows per case, 8 KiB query/result documents, 1 MiB ontology diagnostics and existing separate RDF control-object/query budgets. Its five-second cooperative/elapsed budget refuses late success; it does not forcibly preempt a running OS thread. Unversioned responses retain legacy compatibility during source preparation. The first unified API release must default to `demo/1`; explicit legacy lasts only that contract generation and is refused in its immediate successor. Release identities and caller migration receipts remain gates; this source addition is not a deployed change. Known datasets without current definitions return unavailable; no ranking, model readiness or broad fixture coverage is implied by the current three-case run. Interrupted runs withhold all case payloads, epoch/commit pins and completed counts because they did not reach the final same-incarnation proof. The explicit `case_payloads: "withheld"` marker and null completed count mean unknown evidence, not zero matches or zero work. Timeout and snapshot-change issues remain distinct. A known recreation never returns retired-incarnation rows, including after a completed case. Only a run that reaches the final epoch check can return evidence. Current SDK helpers first require `rdf_demo_checks_v1` from `/version`, before any demo POST, then validate the explicit response version and complete shape. A missing capability sends no demo/mutation POST. Capability means the protocol is understood, not authorization, an enabled dataset route, fixture presence, publication readiness or successful checks. Existing server gates still apply. #### Explicit bounded RDF import `POST /v1/graph/import/rdf?expected_graph_epoch=` (also `/api/graph/import/rdf`) selects one atomic support-aware N-Triples mutation on an existing supported graph. Keep the original epoch and required `Idempotency-Key` unchanged when an outcome is unknown. The qualified predecessor without this binding refuses the explicit epoch; this does not qualify arbitrary historical binaries. Use a qualified server and never fall back by removing the selector. The selector neither creates nor converts a graph, and no support activation endpoint is provided. Use `Content-Type: application/n-triples` with an optional UTF-8 charset. `format=nt`, `ntriples` and `n-triples` are equivalent optional controls. The whole request uses application/default support. Custom owners/scopes, bulk batch/build options, Turtle/N-Quads/TriG, relative-IRI bases, named graphs and blank nodes are unsupported in this slice and refuse before acceptance. Source admission keeps the existing 16 MiB mutation input, 100,000 parsed-statement work, 4,096 distinct support-change and common state/SHACL budgets. A request past a bound is refused as a whole; it is never silently split. Successful selected requests return `object: "rdf_import_commit"`, an original `receipt` (decimal-string epoch, commit sequence, visibility token, captured schema revision, no-op and actual added/removed projection counts), and a separate `idempotent_replay` flag. Receipt fields come from retained durable CoreV1 acceptance and stay unchanged on replay, including after later writes or configuration changes. Equivalent RDF order/comments/duplicates denote the same semantic intent. A changed intent under the same key conflicts. Existing retention is at least seven days; do not promise retries after its retained receipt window. Publication and class embeddings follow separately. Acceptance is not a claim that either is caught up. Without `expected_graph_epoch`, the existing ordinary bulk-import path, statistics, batching, parse-error handling and format support remain unchanged. A support-enabled head continues to refuse that unselected writer. This finite binding adds no durable-import job, large-source compatibility, SDK helper or capability-marker promise. Larger source parity remains a separate activation, use/migration and U2-completion gate. ### Legacy model consumer transition API2026-10-27 source prepares one finite legacy-consumer notice; it is not yet a published release or delivered announcement. Capability `legacy_model_consumers_deprecation_v1` in the existing `/version` capability list recognizes the policy/Link response headers below. `/version` adds no release, configuration, job or transition metadata. The capability is not a graph census, a live rollout clock or retirement completion; absence on an older server does not prove producers stopped. Actual evidence and window state are kept in the operational release receipt. Deterministic `fusion`, `suggest_ranker` and BM25 `calibration` ticks/jobs/auto selection, legacy semantic `shadow-eval` and raw old record-vector `models/query` are deprecated for new adoption. Their selected responses add `LBB-Legacy-Model-Policy` and a deprecation `Link`; existing bodies, statuses, requests and retry/idempotency behavior remain unchanged. No automatic conversion to class-declared search or invented Sunset date is provided. GET job/config and registry history remain supported. Current managed Models/Evals/trials, shared registry/history and class embeddings/ANN/reranking/Ask are separate. Compatibility lasts one actually released unified generation, shared with the status/demo/calibration transition. The immediate successor is planned to refuse new legacy selection/replay/query, preserve configuration disable/history/job reads and stop automatic legacy scheduling. Exact release identities, delivered notice, caller migration, saved configuration decisions and job disposition must be qualified before that successor release; source landing/deployment alone does not start or complete the window. No state or model history is removed here. ## Add links with an LLM URL: https://docs.littlebigbrain.com/guides/add-links-with-an-llm/ Source: docs-site/src/content/docs/guides/add-links-with-an-llm.mdx Description: Some facts live only in your documents. An LLM reads each section and writes them as links. Your code drops links to unknown records, a dry run checks the rest, then you write them. Some facts live only in text. A manual says which account a booking rule is for. An advisory says which library a bug is in. An LLM can read each section and write those facts as links. This guide continues [Load your data](https://docs.littlebigbrain.com/start/load-data/). It uses the same graph, `kb`, the same client and the same files. ### How it works 1. You send the model one section and the records it may link to. 2. The model answers with links as JSON. 3. Your code drops each link to a record you do not have. 4. A dry run checks the rest against the schema. Then you write them. The schema refuses unknown types, links and fields. It cannot know which records your model meant. Step 3 checks that. ### Pick your use case The examples follow the use case you pick. The choice is the same as on [Load your data](https://docs.littlebigbrain.com/start/load-data/#pick-your-use-case). ### Support **Your sources.** A ticket database and your release notes. **The question.** Which release fixed the login bug? **The graph.** ```text Ticket → REPORTED_AS → Bug ← COVERS ← Section → PART_OF → Document ``` ### Accounting **Your sources.** A ledger database and your accounting manual. **The question.** Why was JE-1042 booked to account 6815? **The graph.** ```text JournalEntry → BOOKED_TO → Account ← COVERS ← Section → PART_OF → Document ``` ### Legal **Your sources.** A contract database and the signed contract files. **The question.** Which contracts need consent after a merger? **The graph.** ```text Party ← HAS_PARTY ← Contract ← COVERS ← Section → PART_OF → Document ``` ### Life sciences **Your sources.** A batch record database and your deviation reports. **The question.** Which batches were made from the lot that failed testing? **The graph.** ```text Batch → MADE_FROM → Lot ← COVERS ← Section → PART_OF → Document ``` ### Security **Your sources.** A service catalog and your security advisories. **The question.** Which services are exposed to the log4j bug? **The graph.** ```text Service → DEPENDS_ON → Library ← COVERS ← Section → PART_OF → Document ``` ### 1. Ask the model Send one prompt per section. The prompt names the link, the records the link may point to, and the section: ```text You link document sections to records in a knowledge graph. Add a COVERS link from the section to each record below that the section states a rule or a fact about. Use only these records: Section key: Section name: Section text: Answer with JSON only, in this form: {"triplets": [{"source": {"type": "Section", "key": "", "name": ""}, "relation": "COVERS", "target": {"type": "", "key": "", "name": ""}, "evidence": ""}]} Answer {"triplets": []} when the section is about none of these records. ``` `candidates.json` lists the records the link may point to. Your database already has them, so export them from there. Use any model that can answer with JSON. Collect the answers for all sections in one file, `links.json`. A model can make mistakes. In each example below, one link points to a record that does not exist: ### Support ```json title="candidates.json" [ { "type": "Bug", "key": "AUTH-112", "name": "session token expiry" }, { "type": "Bug", "key": "PDF-31", "name": "PDF fonts missing" } ] ``` ```json title="links.json" { "triplets": [ { "source": { "type": "Section", "key": "release-notes-3.2#1", "name": "Sign-in fixes" }, "relation": "COVERS", "target": { "type": "Bug", "key": "AUTH-112", "name": "session token expiry" }, "evidence": "release-notes-3.2#1" }, { "source": { "type": "Section", "key": "release-notes-3.2#2", "name": "Faster exports" }, "relation": "COVERS", "target": { "type": "Bug", "key": "EXPORT-7", "name": "export runs on one thread" }, "evidence": "release-notes-3.2#2" } ] } ``` The last link is wrong: EXPORT-7 is not in your bugs table. ### Accounting ```json title="candidates.json" [ { "type": "Account", "key": "6815", "name": "Low-value office equipment" }, { "type": "Account", "key": "4400", "name": "Software subscriptions" } ] ``` ```json title="links.json" { "triplets": [ { "source": { "type": "Section", "key": "accounting-manual#3.2", "name": "3.2 Low-value equipment" }, "relation": "COVERS", "target": { "type": "Account", "key": "6815", "name": "Low-value office equipment" }, "evidence": "accounting-manual#3.2" }, { "source": { "type": "Section", "key": "accounting-manual#3.4", "name": "3.4 Software" }, "relation": "COVERS", "target": { "type": "Account", "key": "4400", "name": "Software subscriptions" }, "evidence": "accounting-manual#3.4" }, { "source": { "type": "Section", "key": "accounting-manual#3.4", "name": "3.4 Software" }, "relation": "COVERS", "target": { "type": "Account", "key": "1790", "name": "Prepaid expenses" }, "evidence": "accounting-manual#3.4" } ] } ``` The last link is wrong: account 1790 is not in your accounts table. ### Legal ```json title="candidates.json" [ { "type": "Contract", "key": "MSA-204", "name": "MSA-204: master services agreement" }, { "type": "Contract", "key": "NDA-311", "name": "NDA-311: mutual NDA" } ] ``` ```json title="links.json" { "triplets": [ { "source": { "type": "Section", "key": "MSA-204.pdf#14", "name": "14. Assignment" }, "relation": "COVERS", "target": { "type": "Contract", "key": "MSA-204", "name": "MSA-204: master services agreement" }, "evidence": "MSA-204.pdf#14" }, { "source": { "type": "Section", "key": "MSA-204.pdf#15", "name": "15. Term" }, "relation": "COVERS", "target": { "type": "Contract", "key": "MSA-204", "name": "MSA-204: master services agreement" }, "evidence": "MSA-204.pdf#15" }, { "source": { "type": "Section", "key": "MSA-204.pdf#14", "name": "14. Assignment" }, "relation": "COVERS", "target": { "type": "Contract", "key": "MSA-205", "name": "MSA-205: master services agreement" }, "evidence": "MSA-204.pdf#14" } ] } ``` The last link is wrong: MSA-205 is not in your contracts table. ### Life sciences ```json title="candidates.json" [ { "type": "Lot", "key": "73091", "name": "Lot 73091" }, { "type": "Lot", "key": "73112", "name": "Lot 73112" } ] ``` ```json title="links.json" { "triplets": [ { "source": { "type": "Section", "key": "DR-77#2", "name": "2. Finding" }, "relation": "COVERS", "target": { "type": "Lot", "key": "73091", "name": "Lot 73091" }, "evidence": "DR-77#2" }, { "source": { "type": "Section", "key": "DR-77#3", "name": "3. Action" }, "relation": "COVERS", "target": { "type": "Lot", "key": "73090", "name": "Lot 73090" }, "evidence": "DR-77#3" } ] } ``` The last link is wrong: lot 73090 is not in your lots table. ### Security ```json title="candidates.json" [ { "type": "Library", "key": "log4j-core", "name": "log4j-core" }, { "type": "Library", "key": "jackson-databind", "name": "jackson-databind" } ] ``` ```json title="links.json" { "triplets": [ { "source": { "type": "Section", "key": "SA-2021-44#1", "name": "Affected versions" }, "relation": "COVERS", "target": { "type": "Library", "key": "log4j-core", "name": "log4j-core" }, "evidence": "SA-2021-44#1" }, { "source": { "type": "Section", "key": "SA-2021-44#2", "name": "Fix" }, "relation": "COVERS", "target": { "type": "Library", "key": "log4j-core", "name": "log4j-core" }, "evidence": "SA-2021-44#2" }, { "source": { "type": "Section", "key": "SA-2021-44#2", "name": "Fix" }, "relation": "COVERS", "target": { "type": "Library", "key": "log4j-api", "name": "log4j-api" }, "evidence": "SA-2021-44#2" } ] } ``` The last link is wrong: log4j-api is not in your service catalog. ### 2. Drop links to unknown records A link to an unknown `key` creates a new record. So keep only the links whose target is in `candidates.json`: ### TypeScript ```ts const known = new Set(read("candidates.json").map((r: any) => `${r.type}/${r.key}`)); const links = read("links.json"); links.triplets = links.triplets.filter((t: any) => known.has(`${t.target.type}/${t.target.key}`), ); console.log(links.triplets.length, "links kept"); ``` ### Python ```python known = {(r["type"], r["key"]) for r in read("candidates.json")} links = read("links.json") links["triplets"] = [ t for t in links["triplets"] if (t["target"]["type"], t["target"]["key"]) in known ] print(len(links["triplets"]), "links kept") ``` ### cURL ```sh jq --slurpfile known candidates.json \ '.triplets |= map(select(.target as $t | $known[0] | any(.type == $t.type and .key == $t.key)))' \ links.json > checked.json jq '.triplets | length' checked.json ``` ### 3. Check, then write A dry run checks the links against the schema and writes nothing. It fails like a real write on an unknown type, link or field. Say the model names a link `EXPLAINS`. The error lists the links your schema allows: ### Support ```text unknown relation 'EXPLAINS'; allowed relations: REPORTED_AS, PART_OF, COVERS ``` ### Accounting ```text unknown relation 'EXPLAINS'; allowed relations: BOOKED_TO, PART_OF, COVERS ``` ### Legal ```text unknown relation 'EXPLAINS'; allowed relations: HAS_PARTY, PART_OF, COVERS ``` ### Life sciences ```text unknown relation 'EXPLAINS'; allowed relations: MADE_FROM, PART_OF, COVERS ``` ### Security ```text unknown relation 'EXPLAINS'; allowed relations: DEPENDS_ON, PART_OF, COVERS ``` Send such an error back to the model and ask again. When the dry run passes, it reports the number of links it would write. Then write them: ### TypeScript ```ts await lbb.commitDryRun(links); const written = await lbb.graph("kb").facts.create(links, { idempotencyKey: "kb-links-1" }); await lbb.waitForPublished(written.commit_seq); ``` ### Python ```python lbb.commit_dry_run(links) written = lbb.graph("kb").facts.create(links, idempotency_key="kb-links-1") lbb.wait_for_published(written["commit_seq"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=kb&dry_run=true" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d @checked.json curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: kb-links-1" -d @checked.json until curl -sS "$LBB_URL/v1/graph/publication-status?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done ``` Each link keeps the section key in `evidence`. An answer can then cite the section it came from. ### 4. Follow the path Your database records and your document sections are one graph now. This query starts at a record and follows its links to the section that explains it: ### Support ```sparql title="path.rq" PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: SELECT ?ticket ?bug ?section ?document WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dr:reported_as ?b . ?b rdfs:label ?bug . ?s dr:covers ?b ; rdfs:label ?section ; dr:part_of ?d . ?d rdfs:label ?document . } ``` The result has 1 row: | ticket | bug | section | document | | --- | --- | --- | --- | | `login fails after 3.1` | `session token expiry` | `Sign-in fixes` | `Release notes 3.2` | ### Accounting ```sparql title="path.rq" PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: SELECT ?entry ?account ?rule ?text WHERE { ?e a dclass:journalentry ; rdfs:label ?entry ; dr:booked_to ?a . ?a rdfs:label ?account . ?s dr:covers ?a ; rdfs:label ?rule ; dp:text ?text . } ORDER BY ?entry ``` The result has 2 rows: | entry | account | rule | text | | --- | --- | --- | --- | | `JE-1042: office chairs` | `Low-value office equipment` | `3.2 Low-value equipment` | Book office equipment that costs less than 800 EUR per item to account 6815. Expense it in the month of purchase. Keep the receipt with the entry. | | `JE-1043: design software` | `Software subscriptions` | `3.4 Software` | Book software subscriptions to account 4400. Spread an annual plan over twelve months. | ### Legal ```sparql title="path.rq" PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: SELECT ?contract ?party ?clause WHERE { ?c a dclass:contract ; rdfs:label ?contract ; dr:has_party ?p . ?p rdfs:label ?party . ?s dr:covers ?c ; rdfs:label ?clause ; dp:text ?text . FILTER(CONTAINS(LCASE(?text), "change of control")) } ``` The result has 1 row: | contract | party | clause | | --- | --- | --- | | `MSA-204: master services agreement` | `Meridian Cloud` | `14. Assignment` | ### Life sciences ```sparql title="path.rq" PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: SELECT ?batch ?lot ?finding WHERE { ?b a dclass:batch ; rdfs:label ?batch ; dr:made_from ?l . ?l rdfs:label ?lot . ?s dr:covers ?l ; rdfs:label ?finding . } ``` The result has 1 row: | batch | lot | finding | | --- | --- | --- | | `Batch CVX-A17` | `Lot 73091` | `2. Finding` | ### Security ```sparql title="path.rq" PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: SELECT ?service ?library ?advisory WHERE { ?svc a dclass:service ; rdfs:label ?service ; dr:depends_on ?lib . ?lib rdfs:label ?library . ?s dr:covers ?lib ; rdfs:label ?advisory . } ORDER BY ?advisory ``` The result has 2 rows: | service | library | advisory | | --- | --- | --- | | `Checkout API` | `log4j-core` | `Affected versions` | | `Checkout API` | `log4j-core` | `Fix` | Run the query: ### TypeScript ```ts const { rows } = await lbb.sparqlRows({ query: readFileSync("path.rq", "utf8") }); console.log(rows); ``` ### Python ```python with open("path.rq") as f: for row in lbb.sparql(f.read()): print(row) ``` ### cURL ```sh curl --fail-with-body -sS -G "$LBB_URL/sparql?graph=kb" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Accept: text/csv" \ --data-urlencode query@path.rq ``` Search finds a section by meaning. This query then proves which records it is about. [How search works](https://docs.littlebigbrain.com/start/platform/) shows the two steps together. ### Run it again The same files give the same links. A rerun with the same idempotency key returns the first write and stores nothing new. When a document changes, load its sections again and ask the model again. Use a new idempotency key for the new links. ### Related - [Load your data](https://docs.littlebigbrain.com/start/load-data/): the schema, the database and the documents. - [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/): filter hits by type or by a link such as `COVERS`. - [Query with SPARQL](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): filters, aggregates and longer paths. ## Build with JSON records URL: https://docs.littlebigbrain.com/guides/without-rdf/ Source: docs-site/src/content/docs/guides/without-rdf.mdx Description: Declare a schema, write records, follow a chain of links, validate writes, and replay a decision, all from JSON and the SDKs. No RDF knowledge needed. You can build a complete application on little big brain with JSON. This guide goes further than the [quickstart](https://docs.littlebigbrain.com/start/quickstart/). It adds typed fields, a chain of links of any length, rules and a replayed decision. Underneath, each record is stored as RDF and each graph serves SPARQL. You do not need to know RDF for this guide. The one query and the one rules document each take a few lines. The running example is a medicine recall. A supplier recalls one ingredient lot. Which released batches contain material from that lot? Which active study sites received them? The answer runs through intermediate lots. It is a chain of links, so one join cannot answer it. The four terms below are the whole vocabulary you need. The API calls them entity types, entities, relations and properties. | Term | What it is | JSON example | | --- | --- | --- | | Type | A kind of record | `ReleasedBatch` | | Record | One thing, identified by its type plus a `key` or a `name` | `{ "type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17" }` | | Link | A named connection from one record to another | `PRODUCED_FROM` | | Field | A typed value on one record | `"batch_number": "CVX-A17"` | 1. **Declare the schema.** A graph accepts only the types, links and fields it declares, so declare them first. One `define` call creates the graph with its types and links. One `evolve` call adds the typed fields. Both calls are safe to repeat. A type can include `super_types: ["ParentClass"]`. Here an `ActiveStudySite` is a kind of `StudySite`. Declare each parent in the same document, in any order. Unknown fields and unknown parents are rejected, so the hierarchy is never lost without an error. The graph stores each parent as an `rdfs:subClassOf` statement. A study-site query with `entailment: "subclass"` then matches the active sites too. Use RDF import when you also need OWL axioms, labels and comments. ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "recall", }); const graph = lbb.graph("recall"); await graph.ontology.define({ format: "spec", merge_default: false, source: JSON.stringify({ entity_types: [ { name: "IngredientLot" }, { name: "IntermediateLot" }, { name: "ReleasedBatch" }, { name: "StudySite" }, { name: "ActiveStudySite", super_types: ["StudySite"] }, { name: "RecallNotice" }, { name: "AgentDecision" }, ], relation_types: [ { name: "PRODUCED_FROM", source: ["IntermediateLot", "ReleasedBatch"], target: ["IngredientLot", "IntermediateLot"] }, { name: "RECALLS", source: "RecallNotice", target: "IngredientLot", inverse: "RECALLED_BY" }, { name: "RECEIVED_BATCH", source: ["StudySite", "ActiveStudySite"], target: "ReleasedBatch", inverse: "SHIPPED_TO" }, { name: "DECIDED_ABOUT", source: "AgentDecision", target: "RecallNotice" }, ], }), }); await graph.ontology.evolve({ ops: [ { op: "add_property", name: "lot_number", value_type: "keyword" }, { op: "add_property", name: "batch_number", value_type: "keyword" }, { op: "add_property", name: "recall_date", value_type: "date_time" }, { op: "add_property", name: "based_on_commit_seq", value_type: "i64" }, { op: "add_property", name: "decision_outcome", value_type: "keyword", allowed_values: ["affected", "clear"] }, ], }); ``` ### Python ```python import json, os from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="recall") graph = lbb.graph("recall") lbb.ontology.define({ "format": "spec", "merge_default": False, "source": json.dumps({ "entity_types": [ {"name": "IngredientLot"}, {"name": "IntermediateLot"}, {"name": "ReleasedBatch"}, {"name": "StudySite"}, {"name": "ActiveStudySite", "super_types": ["StudySite"]}, {"name": "RecallNotice"}, {"name": "AgentDecision"}, ], "relation_types": [ {"name": "PRODUCED_FROM", "source": ["IntermediateLot", "ReleasedBatch"], "target": ["IngredientLot", "IntermediateLot"]}, {"name": "RECALLS", "source": "RecallNotice", "target": "IngredientLot", "inverse": "RECALLED_BY"}, {"name": "RECEIVED_BATCH", "source": ["StudySite", "ActiveStudySite"], "target": "ReleasedBatch", "inverse": "SHIPPED_TO"}, {"name": "DECIDED_ABOUT", "source": "AgentDecision", "target": "RecallNotice"}, ], }), }) lbb.ontology.evolve({ "ops": [ {"op": "add_property", "name": "lot_number", "value_type": "keyword"}, {"op": "add_property", "name": "batch_number", "value_type": "keyword"}, {"op": "add_property", "name": "recall_date", "value_type": "date_time"}, {"op": "add_property", "name": "based_on_commit_seq", "value_type": "i64"}, {"op": "add_property", "name": "decision_outcome", "value_type": "keyword", "allowed_values": ["affected", "clear"]}, ], }) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/define?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{ "format": "spec", "merge_default": false, "source": "{\"entity_types\":[{\"name\":\"IngredientLot\"},{\"name\":\"IntermediateLot\"},{\"name\":\"ReleasedBatch\"},{\"name\":\"StudySite\"},{\"name\":\"ActiveStudySite\",\"super_types\":[\"StudySite\"]},{\"name\":\"RecallNotice\"},{\"name\":\"AgentDecision\"}],\"relation_types\":[{\"name\":\"PRODUCED_FROM\",\"source\":[\"IntermediateLot\",\"ReleasedBatch\"],\"target\":[\"IngredientLot\",\"IntermediateLot\"]},{\"name\":\"RECALLS\",\"source\":\"RecallNotice\",\"target\":\"IngredientLot\",\"inverse\":\"RECALLED_BY\"},{\"name\":\"RECEIVED_BATCH\",\"source\":[\"StudySite\",\"ActiveStudySite\"],\"target\":\"ReleasedBatch\",\"inverse\":\"SHIPPED_TO\"},{\"name\":\"DECIDED_ABOUT\",\"source\":\"AgentDecision\",\"target\":\"RecallNotice\"}]}" }' curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"ops": [ {"op": "add_property", "name": "lot_number", "value_type": "keyword"}, {"op": "add_property", "name": "batch_number", "value_type": "keyword"}, {"op": "add_property", "name": "recall_date", "value_type": "date_time"}, {"op": "add_property", "name": "based_on_commit_seq", "value_type": "i64"}, {"op": "add_property", "name": "decision_outcome", "value_type": "keyword", "allowed_values": ["affected", "clear"]} ]}' ``` `allowed_values` is an enforced value set. A later write with `decision_outcome: "maybe"` is rejected at commit time. No rules file is needed. The message is: ```text value for 'decision_outcome' is not one of its allowed values [affected, clear] ``` `inverse` declares a second name for the same link. A query can ask `SHIPPED_TO` or `RECEIVED_BATCH` and read the same fact. `GET /v1/ontology?graph=recall` returns what the graph declares. Its `property_defs` list the field names and types a write may use. 2. **Write records.** A commit carries `triplets` (links) and `entity_properties` (values). Give a record a `key` when a source system already identifies it. A rename then keeps the same record. Two records with the same display name stay distinct. Each triplet can carry a plain `evidence` string, the reason the fact was recorded. ### TypeScript ```ts const lot = { type: "IngredientLot", name: "Valsartan API lot ZH-VAL-2018-07", key: "ZH-VAL-2018-07" }; const granulation = { type: "IntermediateLot", name: "Granulation lot G-17", key: "G-17" }; const batch = { type: "ReleasedBatch", name: "Cardiovex batch CVX-A17", key: "CVX-A17" }; const notice = { type: "RecallNotice", name: "FDA valsartan recall" }; const written = await graph.facts.create( { triplets: [ { source: notice, relation: "RECALLS", target: lot, evidence: "FDA recall notice, 2018-07-13" }, { source: granulation, relation: "PRODUCED_FROM", target: lot, evidence: "Batch record CVX-A17, page 3" }, { source: batch, relation: "PRODUCED_FROM", target: granulation, evidence: "Batch record CVX-A17, page 9" }, { source: { type: "ActiveStudySite", name: "Berlin Cardiology Study Site" }, relation: "RECEIVED_BATCH", target: batch, evidence: "Shipment receipt, Berlin site" }, { source: { type: "StudySite", name: "Oslo Cardiology Study Site" }, relation: "RECEIVED_BATCH", target: batch, evidence: "Shipment receipt, Oslo site (closed 2018-03-31)" }, ], entity_properties: [ { ...lot, properties: { lot_number: "ZH-VAL-2018-07" } }, { ...batch, properties: { batch_number: "CVX-A17" } }, { ...notice, properties: { recall_date: "2018-07-13" } }, ], }, { idempotencyKey: "recall-load-1" }, ); await graph.waitForPublished(written.commit_seq); console.log(written.commit_seq); ``` ### Python ```python lot = {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07"} granulation = {"type": "IntermediateLot", "name": "Granulation lot G-17", "key": "G-17"} batch = {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"} notice = {"type": "RecallNotice", "name": "FDA valsartan recall"} written = graph.facts.create({ "triplets": [ {"source": notice, "relation": "RECALLS", "target": lot, "evidence": "FDA recall notice, 2018-07-13"}, {"source": granulation, "relation": "PRODUCED_FROM", "target": lot, "evidence": "Batch record CVX-A17, page 3"}, {"source": batch, "relation": "PRODUCED_FROM", "target": granulation, "evidence": "Batch record CVX-A17, page 9"}, {"source": {"type": "ActiveStudySite", "name": "Berlin Cardiology Study Site"}, "relation": "RECEIVED_BATCH", "target": batch, "evidence": "Shipment receipt, Berlin site"}, {"source": {"type": "StudySite", "name": "Oslo Cardiology Study Site"}, "relation": "RECEIVED_BATCH", "target": batch, "evidence": "Shipment receipt, Oslo site (closed 2018-03-31)"}, ], "entity_properties": [ {**lot, "properties": {"lot_number": "ZH-VAL-2018-07"}}, {**batch, "properties": {"batch_number": "CVX-A17"}}, {**notice, "properties": {"recall_date": "2018-07-13"}}, ], }, idempotency_key="recall-load-1") graph.wait_for_published(written["commit_seq"]) print(written["commit_seq"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Idempotency-Key: recall-load-1" \ -H "Content-Type: application/json" \ -d '{ "triplets": [ {"source": {"type": "RecallNotice", "name": "FDA valsartan recall"}, "relation": "RECALLS", "target": {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07"}, "evidence": "FDA recall notice, 2018-07-13"}, {"source": {"type": "IntermediateLot", "name": "Granulation lot G-17", "key": "G-17"}, "relation": "PRODUCED_FROM", "target": {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07"}, "evidence": "Batch record CVX-A17, page 3"}, {"source": {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"}, "relation": "PRODUCED_FROM", "target": {"type": "IntermediateLot", "name": "Granulation lot G-17", "key": "G-17"}, "evidence": "Batch record CVX-A17, page 9"}, {"source": {"type": "ActiveStudySite", "name": "Berlin Cardiology Study Site"}, "relation": "RECEIVED_BATCH", "target": {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"}, "evidence": "Shipment receipt, Berlin site"}, {"source": {"type": "StudySite", "name": "Oslo Cardiology Study Site"}, "relation": "RECEIVED_BATCH", "target": {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"}, "evidence": "Shipment receipt, Oslo site (closed 2018-03-31)"} ], "entity_properties": [ {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07", "properties": {"lot_number": "ZH-VAL-2018-07"}}, {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17", "properties": {"batch_number": "CVX-A17"}}, {"type": "RecallNotice", "name": "FDA valsartan recall", "properties": {"recall_date": "2018-07-13"}} ] }' ``` The response reports `commit_seq`. The code then waits until the commit is published. The reads in the next steps then see it. Field writes merge. A later commit that sends only `recall_date` keeps `lot_number` in place. A value of the wrong type fails the whole commit before anything is written. So does an unknown field or an unknown type. For a large load, stream the same records as newline-delimited JSON. Use the [durable import job](https://docs.littlebigbrain.com/sdks/http-api/#bulk-ndjson-ingest). 3. **Query a single hop without query text.** The structured query takes link names and fields as JSON. This one asks which records were produced directly from the recalled lot: ### TypeScript ```ts const direct = await lbb.entities.filterByAttributes({ patterns: [{ subject: { var: "made" }, predicate: "PRODUCED_FROM", object: { var: "lot" } }], where: [{ var: "lot", field: "lot_number", value: "ZH-VAL-2018-07" }], select: ["made"], }); ``` ### Python ```python direct = lbb.entities.filter_by_attributes( patterns=[{"subject": {"var": "made"}, "predicate": "PRODUCED_FROM", "object": {"var": "lot"}}], where=[{"var": "lot", "field": "lot_number", "value": "ZH-VAL-2018-07"}], select=["made"], ) ``` That returns the granulation lot, one hop away, with its type and name. The released batch is two hops away, and a real genealogy can have many. SPARQL property paths follow a chain of unknown length. So the next step uses query text. 4. **Follow the chain with one SPARQL query.** Each name you declared in JSON has a fixed IRI. A query needs three prefixes and nothing else: | You declared | SPARQL IRI | Rule | | --- | --- | --- | | Entity type `ReleasedBatch` | `` | lowercase | | Relation `PRODUCED_FROM` | `` | lowercase | | Property `batch_number` | `` | lowercase | | An entity's `name` | `rdfs:label` | plain string | `dr:produced_from+` means "one or more `PRODUCED_FROM` links in a row". The query finds the released batches whose genealogy reaches the recalled lot. It also finds the active site that received each one: ```sparql PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: SELECT ?batchNumber ?site WHERE { ?lot dp:lot_number "ZH-VAL-2018-07" . ?batch a dclass:releasedbatch ; dr:produced_from+ ?lot ; dp:batch_number ?batchNumber . OPTIONAL { ?siteNode a dclass:activestudysite ; dr:received_batch ?batch ; rdfs:label ?site . } } ORDER BY ?batchNumber ?site ``` In the code below, `genealogyQuery` (`genealogy_query` in Python) holds this query text. Pass the write's `commit_seq` as the floor. The response names the commit it answered from, as `snapshot.served_at_seq`. Publication may not cover the floor yet. Then the server answers with a retryable `429` and a `Retry-After`. The SDKs wait and retry. With cURL, `--retry` does the same. ### TypeScript ```ts const exposure = await lbb.sparqlRows( { query: genealogyQuery }, { consistency: "eventual", minIndexedSeq: written.commit_seq }, ); const readAt = exposure.snapshot!.served_at_seq!; // keep this for step 6 ``` ### Python ```python exposure = lbb.sparql(genealogy_query, consistency="eventual", min_indexed_seq=written["commit_seq"]) read_at = exposure.snapshot["served_at_seq"] # keep this for step 6 ``` ### cURL ```sh curl --retry 10 --fail-with-body -sS -X POST \ "$LBB_URL/v1/query/sparql-text?graph=recall&consistency=eventual&min_indexed_seq=$COMMIT_SEQ" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d "{\"query\": $(jq -Rs . < genealogy.sparql)}" ``` The result is one row: batch `CVX-A17`, site `Berlin Cardiology Study Site`. The Oslo site received the same batch. It is a `StudySite`, so the `activestudysite` pattern leaves it out. That is the reportable answer. A strong read is exact as soon as the commit is acknowledged. Pass `consistency: "strong"` with the same floor. Its response has no `snapshot` block. Use it when you need the answer now. Use the eventual read with a floor when you must record which commit you read. 5. **Validate writes.** `allowed_values` already guards one field. For rules across fields and links, publish SHACL shapes. This is the one Turtle document in the guide. It uses the same three prefixes: ```turtle @prefix sh: . @prefix dclass: . @prefix dr: . @prefix dp: . a sh:NodeShape ; sh:targetClass dclass:releasedbatch ; sh:property [ sh:path dp:batch_number ; sh:maxCount 1 ; sh:message "A released batch carries at most one batch number." ] ; sh:property [ sh:path dr:produced_from ; sh:minCount 1 ; sh:message "A released batch names the lot it was produced from." ] . ``` Publish in `warn` mode first and read the conformance report. Then publish the same source in `reject` mode. In the code, `shapesTurtle` (`shapes_turtle` in Python) holds the Turtle document above. ### TypeScript ```ts await graph.schema.publish({ desired_mode: "warn", shapes: { source: shapesTurtle, format: "turtle" } }); const report = await graph.ontology.conformance({ consistency: "strong" }); if (report.conforms) { await graph.schema.publish({ desired_mode: "reject", shapes: { source: shapesTurtle, format: "turtle" } }); } ``` ### Python ```python lbb.schema.publish({"desired_mode": "warn", "shapes": {"source": shapes_turtle, "format": "turtle"}}) report = lbb.ontology.conformance(consistency="strong") if report.conforms: lbb.schema.publish({"desired_mode": "reject", "shapes": {"source": shapes_turtle, "format": "turtle"}}) ``` The strong conformance read returns a retryable `429` until the report for the new shapes exists. Then it returns `conforms`, `result_count` and `validated_at_seq`. In `reject` mode, a write that breaks a shape returns HTTP 400. Nothing is stored. A shipment to a new batch that names no lot fails like this: ```text schema validation failed in reject mode with 1 SHACL violation(s): http://www.w3.org/ns/shacl#MinCountConstraintComponent on '' path '': A released batch names the lot it was produced from. Preflight with dry_run=true to get the full structured report (schema_validation.results) ``` To get that report as data before a real write, use a dry run. Send the same body to `commitDryRun` / `commit_dry_run`. Then `schema_validation.conforms` is `false`. `schema_validation.results` lists each violation with its `component`, `focus_node`, `result_path` and `message`. An agent can read that list, fix its own output and retry. :::note[Put required links in the same commit] With `sh:minCount` on `dr:produced_from`, a new released batch needs its first `PRODUCED_FROM` link. Both must arrive in one commit. A batch written alone is a violation the moment it lands. ::: 6. **Record the decision and replay it later.** An agent that acts on a query result should store what it read. Write the decision as a record. Set its `based_on_commit_seq` to the `served_at_seq` from step 4. Link it to the notice it was about: ### TypeScript ```ts const decision = { type: "AgentDecision", name: "Valsartan lot impact assessment", key: "decision-2018-07-13-01" }; await graph.facts.create( { triplets: [{ source: decision, relation: "DECIDED_ABOUT", target: notice, evidence: "genealogy query, batch CVX-A17 and Berlin site exposed" }], entity_properties: [{ ...decision, properties: { based_on_commit_seq: readAt, decision_outcome: "affected" } }], }, { idempotencyKey: "decision-2018-07-13-01" }, ); // Weeks later: rerun the same query against the state the agent read. const replay = await lbb.sparqlRows({ query: genealogyQuery, as_of_commit_seq: readAt }); ``` ### Python ```python decision = {"type": "AgentDecision", "name": "Valsartan lot impact assessment", "key": "decision-2018-07-13-01"} graph.facts.create({ "triplets": [{"source": decision, "relation": "DECIDED_ABOUT", "target": notice, "evidence": "genealogy query, batch CVX-A17 and Berlin site exposed"}], "entity_properties": [{**decision, "properties": {"based_on_commit_seq": read_at, "decision_outcome": "affected"}}], }, idempotency_key="decision-2018-07-13-01") # Weeks later: rerun the same query against the state the agent read. replay = lbb.sparql(genealogy_query, as_of_commit_seq=read_at) ``` Suppose a later commit retracts the Berlin shipment. The receipt was filed against the wrong site. A query of the latest data then returns batch `CVX-A17` with no site. The replay still returns `CVX-A17` with `Berlin Cardiology Study Site`. Its `snapshot` echoes `as_of_commit_seq`. The decision and the rows it rested on stay together. Reads of a retained commit are exact. A commit may no longer be retained. Then the read fails with `historical_generation_unavailable`. It never returns a nearby state. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) covers retention and the change log. ### Where the JSON path stops The type hierarchy stays on the JSON path. Step 1 gives `ActiveStudySite` the parent `StudySite`. The graph stores that link as an `rdfs:subClassOf` statement. A query for `dclass:studysite` with `entailment: "subclass"` also matches the active sites. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/#add-a-type-hierarchy-to-json-records). Three things need RDF statements. Each is optional, and each works on the graph you built above without changing it. - **OWL axioms.** OWL can declare that a batch with a recalled lot has a reportable exposure. One plain pattern then returns the answer. The JSON schema has no field for such rules, so load them with [RDF import](https://docs.littlebigbrain.com/guides/load-rdf/). See [Answer a question with OWL](https://docs.littlebigbrain.com/guides/reasoning/#answer-a-question-with-owl). - **Records that already are RDF.** A dump from another triple store loads into the same graph with `facts.importRdf`. Its statements keep their source predicates, next to the JSON-declared ones. - Load the JSON records first and the RDF second. A graph whose **first** write is RDF is RDF-native. It refuses the JSON path with `400 rdf_native_graph`. See [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/). - **Standard tooling.** YASGUI, Protégé and RDFLib connect to the graph's `/sparql` endpoint with the stack API key. A `CONSTRUCT` query there returns Turtle. That is the export path for the whole graph. ### Related - [Use cases](https://docs.littlebigbrain.com/start/use-cases/): five more agent decisions with the same modeling and query patterns. - [Coming from SQL](https://docs.littlebigbrain.com/start/database-comparison/#if-you-use-a-relational-database): the mapping from tables and joins to types and links. - [Query & validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): the full structured query surface and SHACL enforcement modes. - [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/): every route this guide called. ## Search by meaning URL: https://docs.littlebigbrain.com/guides/search-by-meaning/ Source: docs-site/src/content/docs/guides/search-by-meaning.mdx Description: Declare an embedding on a type of record, let the platform keep its vectors current, and search by meaning with filters by type and by link, or inside a SPARQL query. Each hit is checked against the graph. A SPARQL query finds what you can name: a label, a field value, a path. Search by meaning finds what you can only describe. An example is "which service decides if a card payment is fraudulent". You declare an **embedding** on a type of record. The platform builds one text per record from the fields you choose. It embeds the text with the graph's model. The platform keeps the vectors current as the graph changes. You run no embedding model and no vector store. Search by meaning is on every plan, Free included. On Free, the vectors count toward the 30 GiB allowance. A search embeds your question with the same model and finds the nearest records. It covers every searchable type, or the types you pick. Each hit is checked against one published commit of the graph. A deleted or re-typed record does not come back. The running example is a service catalog. Each service is a record of the type `Service`. It has a name, a description and `CALLS` links to other services. In this guide, a type is called a **class**, as in the API. A class has a global name, an IRI. For a type you declare in JSON, the IRI is the name in lowercase after `https://littlebigbrain.com/class/`. So the `Service` class is `https://littlebigbrain.com/class/service`. [Core concepts](https://docs.littlebigbrain.com/start/concepts/) explains these names. 1. **Preview the text.** A preview stores nothing and calls no model. It resolves the fields and shows the exact text of some records. Without `from`, the server picks the fields from a sample of the class. It takes the label, frequent text values and the labels of linked records. It leaves out ids, keys and URLs, such as `source_id` and `source_url`. It also leaves out a value that is the same on every record. Name such a field in `from` to embed it. `candidates` lists each fact of the class, chosen or not. It shows the share of records that have the fact, and example values. ### cURL ```sh curl -X POST "$LBB_URL/v1/embeddings/preview" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"class": "https://littlebigbrain.com/class/service", "from": ["label", "description", "calls/label"], "sample": 3}' ``` ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, }); const SERVICE = "https://littlebigbrain.com/class/service"; const preview = await lbb.embeddings.preview({ class: SERVICE, from: ["label", "description", "calls/label"], sample: 3, }); console.log(preview.samples.map((s) => s.text)); ``` ### Python ```python import os from lbb import LbbClient client = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"]) SERVICE = "https://littlebigbrain.com/class/service" preview = client.embeddings.preview( SERVICE, from_=["label", "description", "calls/label"], sample=3 ) ``` ### MCP ```text lbb_embeddings action=preview class=https://littlebigbrain.com/class/service from=["label", "description", "calls/label"] sample=3 ``` The text of one record is one line per field: ```text service: risk-service description: Scores card payments for fraud risk before the charge is captured. calls: payment-service, stripe-api ``` 2. **Declare the embedding.** The declaration stores the recipe: the class, the fields and the model. Then it starts the embed job. The name defaults to the last part of the class IRI, `service`. A second declaration with the same recipe does nothing. On an existing embedding, a declaration changes only what it names. Without `from`, the fields stay. Without `model`, the model stays. ### cURL ```sh curl -X PUT "$LBB_URL/v1/embeddings" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"class": "https://littlebigbrain.com/class/service", "from": ["label", "description", "calls/label"]}' ``` ### TypeScript ```ts await lbb.embeddings.declare({ class: SERVICE, from: ["label", "description", "calls/label"], }); ``` ### Python ```python client.embeddings.declare(SERVICE, from_=["label", "description", "calls/label"]) ``` ### MCP ```text lbb_embeddings_manage action=declare class=https://littlebigbrain.com/class/service from=["label", "description", "calls/label"] ``` An agent previews first and shows you the text before it declares. 3. **Wait until it serves.** The first version builds in the background. While it builds, the status shows a `building` version with its progress. Then it shows a `serving` version. That version reports its entries, the commit it covers (`embedded_through_seq`) and `lag_commits`. A search before the first version serves answers 409 `embedding_not_ready` with a `Retry-After`. ### cURL ```sh curl "$LBB_URL/v1/embeddings?name=service" -H "Authorization: Bearer $LBB_API_KEY" ``` ### TypeScript ```ts let status = await lbb.embeddings.get("service"); while (!status.serving) { await new Promise((resolve) => setTimeout(resolve, 2_000)); status = await lbb.embeddings.get("service"); } ``` ### Python ```python import time status = client.embeddings.get("service") while not status.get("serving"): time.sleep(2) status = client.embeddings.get("service") ``` ### MCP ```text lbb_embeddings action=get name=service ``` 4. **Search.** The search covers every searchable class of the graph. Each hit names its `class`. Here, `filter` narrows the search to the services (see [Filters](#filters)). `include: ["text"]` returns the embedded text of each hit. ### cURL ```sh curl -X POST "$LBB_URL/v1/search" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"text": "card payments", "top_k": 5, "include": ["text"], "filter": [{"class": "https://littlebigbrain.com/class/service"}]}' ``` ### TypeScript ```ts const found = await lbb.embeddings.search({ filter: [{ class: SERVICE }], text: "card payments", top_k: 5, include: ["text"], }); for (const hit of found.hits) console.log(hit.score.toFixed(3), hit.label, hit.class); ``` ### Python ```python found = client.embeddings.search( "card payments", filter_=[{"class": SERVICE}], top_k=5, include=["text"], ) ``` ### MCP ```text lbb_query mode=search filter=[{"class": "https://littlebigbrain.com/class/service"}] text="card payments" top_k=5 include=["text"] ``` ### What a response tells you Each hit has the record's `iri`, `label`, `score` and `class`. The score is the cosine similarity. The hit also has its `text` when you ask for it. The response also states how current it is: | Field | Meaning | | --- | --- | | `embeddings` | One entry per searched class: its version, model, watermark, lag, a `building_version` while a change of fields or model builds, and its hits in this response. | | `not_ready` | Classes in scope still building their first version; the search skipped them. | | `served_at_seq` | The published commit every hit was checked against. | | `head_seq` | The newest commit of the graph when the search ran. | | `embedded_through_seq` | The oldest commit the searched vectors cover. | | `lag_commits` | The most commits one searched class is behind `head_seq`. At 0, the search found every commit. The publication and then the embed job catch up by themselves. | | `timings` | Milliseconds per phase: `embed_ms` (the model call for the question), `index_ms`, `rerank_ms` (the exact vectors of the best candidates), `check_ms`, `relevance_ms` (the rerank model), and others. | | `rerank` | With a [rerank](#rerank-the-best-hits): its status, model and the hits it read. | A repeated question is served from a cache and calls no model. ### Search your own write A commit becomes searchable in two steps. First it is published. Then the embed job embeds the changed records. This usually takes a few seconds. `GET /v1/embeddings` shows both steps: | Field | Meaning | | --- | --- | | `head_seq` | The newest commit of the graph. | | `published_seq` | The newest published commit. | | `serving.embedded_through_seq` | The newest commit the vectors cover. | | `lag_commits` | `head_seq` − `serving.embedded_through_seq`. At 0, a search finds every commit. | To search right after a write, pass the write's `commit_seq` as `min_commit_seq`. The search waits up to 3 seconds for the vectors to cover that commit. If they do not, it answers 429 `read_your_writes_pending` with a `Retry-After`. Retry with the same value. A class still building its first version does not count. ### cURL ```sh curl -X POST "$LBB_URL/v1/search" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"text": "card payments", "min_commit_seq": 128}' ``` ### TypeScript ```ts const written = await lbb.graph("main").facts.create({ triplets }); const found = await lbb.embeddings.search({ text: "card payments", min_commit_seq: written.commit_seq, }); ``` ### Python ```python written = client.graph("main").facts.create({"triplets": triplets}) found = client.embeddings.search("card payments", min_commit_seq=written["commit_seq"]) ``` When the search answers, every hit was checked at that commit or a later one. ### Rerank the best hits Similarity finds the records near your words. It does not read them. A record can be near the words and still miss the question. The **rerank** reads the best hits with your words. It puts first the hits that answer them. The rerank model is Jev, a decision model from TypeSafe. On BEIR SciFact, a public set of science claims, the rerank lifted nDCG@10 from 0.713 to 0.797 over 300 test queries. It adds about 0.3 s to a search. Turn it on for a graph. Every search of the graph then reranks: the console, the API and your agents. In the console, the **Rerank** switch sits beside each search box. ### cURL ```sh curl -X PUT "$LBB_URL/v1/search/settings" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"rerank": true}' ``` ### TypeScript ```ts await lbb.embeddings.setSearchSettings({ rerank: true }); ``` ### Python ```python client.embeddings.set_search_settings(rerank=True) ``` One search can also decide for itself. `rerank: true` reranks it, and `rerank: false` keeps the similarity order. ### cURL ```sh curl -X POST "$LBB_URL/v1/search" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"text": "which service decides if a card payment is fraudulent", "top_k": 5, "rerank": true}' ``` ### TypeScript ```ts const ranked = await lbb.embeddings.search({ text: "which service decides if a card payment is fraudulent", top_k: 5, rerank: true, }); for (const hit of ranked.hits) console.log(hit.relevance, hit.score, hit.label); ``` ### Python ```python ranked = client.embeddings.search( "which service decides if a card payment is fraudulent", top_k=5, rerank=True ) ``` ### MCP ```text lbb_query mode=search text="which service decides if a card payment is fraudulent" top_k=5 rerank=true ``` - The rerank reads the best `2 × top_k` hits, at least 20 and at most 40, or the graph's `rerank_depth` (20 to 80). It reads only hits that passed the graph check and your filters. - Each hit gets `relevance`: the probability that it answers your words. `score` stays the similarity. - The response's `rerank` says how it went: `applied`, `failed`, `unavailable` or `limited`. It also names the model and the hits it read. - A rerank never fails a search. When the model does not answer within 2.5 s, the hits keep the similarity order. - A repeated search reuses the answers and calls no model. - `GET /v1/search/settings` shows the graph's settings and the model. - Three more settings tune the order. `rerank_depth` (20 to 80) is how many hits the model reads. `blend` (0 to 1) mixes the two orders: `blend × relevance + (1 − blend) × similarity`; 1 is the rerank order and 0 the similarity order. `probe_factor` (1 to 4) widens the vector search over large indexes. - A `PUT` changes only the fields you send. `null` sets a field back to its default: `{"blend": null}`. - A tuning session (`POST /v1/search/tuning`) tries settings on the graph's own searches and proposes the best. See [Check model quality](https://docs.littlebigbrain.com/guides/check-model-quality/). - A SPARQL query reranks only when it says so: see [Rerank inside a query](#rerank-inside-a-query). - Your app may show fewer hits than it gets, for example only hits with a relevance of 0.3 or more. Send that cut with the user's words: `"request": "triple stores", "shown": {"min_score": 0.3, "min_relevance": 0.3}`. The eval trace then keeps each hit's relevance and whether your app showed it, and a test of the search checks only the hits your app shows. The cut changes no hit. ### Filters `filter` is one list of conditions. Each hit must meet all of them. The graph checks each hit, so a hit that fails a condition does not come back. A change counts as soon as it is published. ```sh curl -X POST "$LBB_URL/v1/search" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"text": "fraud checks", "filter": [{"class": "https://littlebigbrain.com/class/service"}, {"via": "calls", "to": "payment-service"}]}' ``` - **A class condition**, `{"class": …}`, takes one IRI or a list. It keeps the records of those classes and of their subclasses (`rdfs:subClassOf`). A search takes one class condition. List several classes in it. - An embedding on a class covers its subclasses. A condition on a subclass reads the embedding of the class above it. It keeps only the subclass's records. - **A relationship condition**, `{"via": …, "to": …}`, keeps the hits linked to a record. `via` names the link, and `to` names the record. - `to` is an IRI or a name. A name matches the labels of the records the link reaches. A small spelling slip still resolves when one record is clearly closest. - `direction: "in"` reverses the link. `{"via": "impacted", "direction": "in", "to": "checkout outage"}` keeps what the outage impacted. Several conditions must all hold. A list in `to` accepts any of its records. - The response's `filter` shows each condition as resolved, in order, and how each name matched. `explain: true` shows the plan without running the search. - An unknown link or name answers 400 with the options. An agent that plans the search can then correct itself. - When the class tree changes, the embed job rebuilds the embedding. It reuses the vectors of texts that did not change. ### Search inside a SPARQL query A search can be one pattern of a SPARQL query. Then any pattern can filter the hits: a link, a value, a date, a path or a type. The rest of the query joins, groups and orders the hits like other rows. The search predicates have the prefix `search:`, for `https://littlebigbrain.com/search#`. The syntax is standard SPARQL, so any SPARQL parser and client reads the query. The meaning of these predicates is an extension of this engine. In the SPARQL standard, a pattern matches the facts stored in the graph. Another SPARQL engine runs the query and returns no rows. Other engines extend SPARQL in the same way, for example Jena's `text:query`. This query finds the services nearest to "card payments" among those that call `payment-service`: ```sparql PREFIX search: PREFIX dclass: PREFIX dr: PREFIX rdfs: SELECT ?service ?score ?name WHERE { ?service search:similarTo "card payments" ; search:score ?score . ?service a dclass:service ; rdfs:label ?name ; dr:calls ?callee . ?callee rdfs:label "payment-service" . } ORDER BY DESC(?score) LIMIT 5 ``` Send it to `POST /v1/query/sparql-text` or to the `/sparql` endpoint. | Pattern | What it does | | --- | --- | | `?x search:similarTo "text"` | Finds the records nearest to the text. | | `?x search:similarTo ` | Finds the records nearest to that record. It calls no model. | | `?x search:similarTo "[0.1, …]"^^search:vector` | Finds the records nearest to your vector. It must have the model's dimension. | | `?x search:score ?s` | Binds the score. | | `?x search:top 25` | Sets the number of hits, 1 to 1,000. The default is the query's `LIMIT`, then 10. | | `?x search:embedding "service"` | Searches one embedding only. | | `?x search:rerank true` | Lets the rerank model keep the hits that answer the text. | | `?x search:relevance ?r` | Binds the rerank model's answer, 0 to 1. | Put the search in the main group of the query. A search inside `OPTIONAL`, `UNION`, `MINUS` or a subquery answers 400. The message names the place. `FILTER(?score > 0.8)` keeps only close hits. The engine applies it after the search, so it can return fewer rows than the limit. #### How the engine runs it The engine picks a plan from the rest of the query. It reads the graph at the query's commit. - **Nearest.** No other pattern uses `?x`. This is the search of `POST /v1/search`, with the same scores. - **Filter first.** The engine reads the records that match the rest of the query. When at most 20,000 match, it scores only those records. A narrow filter keeps its recall. - **Search first.** The engine reads the nearest part of the index and keeps the records that match. It picks this plan for a broad filter: more matches than records in that part of the index. With more than 20,000 matches, it reads more of the index until it has enough hits, for at most 4 rounds. The engine counts both costs from the index metadata before it reads any vectors. The response reports them in `search.estimate`. A filter-first answer scores every match, so it misses none. A search-first answer has the recall of a search without a filter. The response of `POST /v1/query/sparql-text` has a `search` object. It names the plan and its counts. It also gives the time of each phase. `complete: false` means a search-first plan found fewer hits than asked. `as_of_commit_seq` reads the graph at an older commit. The filters and the rest of the query then see that commit. The vectors come from the current version, and `lag_commits` reports how far they trail. #### Rerank inside a query `search:rerank true` runs the [rerank](#rerank-the-best-hits) inside the query. `search:relevance` binds the model's answer for each hit. ```sparql PREFIX search: PREFIX rdfs: SELECT ?service ?name ?relevance WHERE { ?service search:similarTo "which service decides if a card payment is fraudulent" ; search:rerank true ; search:relevance ?relevance ; search:top 5 . ?service rdfs:label ?name . } ORDER BY DESC(?relevance) ``` - The engine finds the best `2 × top` hits that match the query, at least 20 and at most 40. The rerank model reads them with your text. - The query keeps the `top` hits with the highest relevance. `?score` stays the similarity. - Order by `?relevance` to show the model's order. The rows follow the query's `ORDER BY`. - The query decides. The graph's rerank setting applies to `POST /v1/search` only. So the same query always runs the same search. - A rerank never fails the query. Without an answer, the query binds the most similar hits, and `?relevance` stays unbound. - A rerank needs text. With a record or a vector as the query, it answers 400. - The response's `search.rerank` says how it went, as on `POST /v1/search`. ### Plan a search (for agents) An agent can plan a search with three reads. It needs no extra endpoint. 1. **List the searchable classes.** Call `GET /v1/embeddings`, or MCP `lbb_embeddings action=list`. It gives each class and the facts of its text. 2. **Read the relationships of a class.** The schema is data, so SPARQL shows it. This query reads a sample of 500 records: ```sparql PREFIX rdf: PREFIX rdfs: SELECT ?p (COUNT(DISTINCT ?s) AS ?n) (SAMPLE(?l) AS ?example) WHERE { { SELECT ?s WHERE { ?s a } LIMIT 500 } ?s ?p ?o . FILTER(isIRI(?o) && ?p != rdf:type) OPTIONAL { ?o rdfs:label ?l } } GROUP BY ?p ORDER BY DESC(?n) ``` It lists each relationship with a count and one example name. For the links into the class, write `?o ?p ?s`. 3. **Check the plan.** Send the search with the filter and `explain: true`. The response shows the resolved relationship and entities. It also shows the allowed count and the mode. It calls no model. Then run the search. If a name or a relationship is wrong, the error lists the options. The agent can correct the plan and try again. ### One model per graph All classes of a graph use one embedding model. One question then gets one vector, and scores compare across classes. The first embedding sets the model. The next ones take it. A declaration that names another model is refused. `PUT /v1/embeddings/model` moves all classes to a new model. The SDKs call it `lbb.embeddings.setModel` and `client.embeddings.set_model`. MCP calls it `lbb_embeddings_manage action=model`. Each class builds a new version while the old one serves. The graph switches to the new model when all classes are ready. A search never mixes two models. ### How the vectors stay current After each published commit, the embed job reads what the commit changed. It embeds again only the records whose text changed. This includes records that link to a renamed record through a field such as `calls/label`. Records that leave the class are removed. A change of fields or model builds a new version while the current one keeps serving. The new version replaces it when it covers the same commits. It reuses the vectors of texts that did not change. The text of one record must fit the model's input limit. For the OpenAI embedding models, the limit is 8,191 tokens. A longer text is shortened from its longest values first, so the short fields stay whole. ### What it costs You pay only for storage. Vectors are stored data. They count toward your storage like the rest of your graph (see [Usage & billing](https://docs.littlebigbrain.com/start/billing/)). At 1,536 dimensions, two measured catalogs took 7.3 KB and 7.8 KB per record. That includes the text. Older versions and merged runs stay stored until the graph's index cleanup removes them. **Billing** lists the vectors of each environment by graph and embedding. It shows their monthly cost at your storage rate. Model tokens are not billed. A monthly model budget per environment stops runaway use. When the budget is spent, requests that need the model answer 429 `embedding_budget_exceeded`. The error names the date the budget resets. After the first build, only changed texts are embedded again. ### Errors | HTTP | `code` | Meaning | | --- | --- | --- | | 400 | `invalid_request` | The declaration or search is malformed. An unknown field name lists the candidates; an unknown body field, such as `where`, is refused. | | 400 | `parameter_missing` | A search without `text`. | | 404 | `not_found` | No embedding with that name, or none on the class. | | 409 | `embedding_not_ready` | The first version still builds. Retry after `Retry-After`. | | 429 | `embedding_budget_exceeded` | The monthly model budget is spent. The budget is a limit and adds no charge. Do not retry before the reset date. | | 429 | `server_busy` | Briefly contended. Retry after `Retry-After`. | | 503 | `embeddings_unavailable` | The server has no embedding model. | ### The name of each hit Each hit shows a name. By default it comes from the ontology. - `skos:prefLabel` names the hits when the records use it. - Next, a property your graph declares `rdfs:subPropertyOf rdfs:label`. - Next, `rdfs:label`, then `schema:name`, `foaf:name` or `dcterms:title`. Name another fact with `title`, for example `"title": "display_name"`. Some labels are keys, like `yc-job:94386`. Then the preview suggests a fact that reads as a name. The response calls it `title_suggestion`. For a new search, the console selects the suggestion in **Name of each hit**. For an existing search, it offers a **Use** button. You confirm when you save. A new name reuses every vector, so it calls no model. ```bash curl -X PUT "$LBB_URL/v1/embeddings" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"class": "https://littlebigbrain.com/class/jobposting", "title": "display_name"}' ``` ### Change or remove an embedding Declare the new fields or model to build a new version. `DELETE /v1/embeddings?name=service&confirm=service` removes the embedding and its vectors. To search it again, declare it again and wait for the build. With MCP, `lbb_embeddings_delete` asks for the name twice. ## Answer questions in plain words URL: https://docs.littlebigbrain.com/guides/answer-questions/ Source: docs-site/src/content/docs/guides/answer-questions.mdx Description: Send a question in plain words. The server describes your graph, picks the kind of question, runs queries in a short loop, reads the rows, and answers with the records it cites. 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: . @prefix c: . @prefix e: . @prefix 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: \nPREFIX class: \nPREFIX rdfs: \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: \nSELECT ?s WHERE { ?s p:team }" }], "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. ## Query with SPARQL and validate writes with SHACL URL: https://docs.littlebigbrain.com/guides/sparql-and-shacl/ Source: docs-site/src/content/docs/guides/sparql-and-shacl.mdx Description: Count, filter and follow links between records with SPARQL. Publish SHACL rules for your records, read the conformance report, and reject writes that break a rule. This page is the reference for exact reads and checked writes. Use SPARQL to count, filter and follow the links between records. Use SHACL rules to check what gets written. The examples extend the support graph from the [quickstart](https://docs.littlebigbrain.com/start/quickstart/), and [core concepts](https://docs.littlebigbrain.com/start/concepts/) explains the names. ### Choose a surface | You want | Use | | --- | --- | | Joins, paths, filters and aggregates in standard SPARQL | [SPARQL 1.1 text](#sparql-11-text): `POST /v1/query/sparql-text` | | A query that app code builds as JSON, with typed fields and no query text | [Structured query](#structured-query): `POST /v1/query/sparql` | | YASGUI, Protégé, RDFLib or another SPARQL client | [The `/sparql` endpoint](#external-sparql-tools) | | Rules that check your records and every write | [SHACL](#published-shacl-conformance): `POST /v1/schema/publish` | | Facts that follow from your schema | [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/) | The three query surfaces read the same data. They follow the same rules for which commit they serve. ### Example data The examples use the quickstart's `support` graph with two more ticket fields. `severity` is a keyword, and `opened_at` is a date and time. Ticket 4823 gets no severity yet. The conformance report in the SHACL section lists it. Run the quickstart through step 3 first. Then add the fields. The TypeScript and Python snippets on this page continue one script. ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); await lbb.ontology.evolve({ ops: [ { op: "add_property", name: "severity", value_type: "keyword" }, { op: "add_property", name: "opened_at", value_type: "date_time" }, ], }); const fields = await lbb.graph("support").facts.create( { entity_properties: [ { type: "Ticket", key: "4821", name: "login fails after 3.1", properties: { severity: "high", opened_at: "2026-09-02T09:15:00Z" } }, { type: "Ticket", key: "4822", name: "invoice PDF is blank", properties: { severity: "medium", opened_at: "2026-09-03T14:40:00Z" } }, { type: "Ticket", key: "4823", name: "CSV export times out", properties: { opened_at: "2026-09-04T08:05:00Z" } }, ], }, { idempotencyKey: "sparql-guide-fields-1" }, ); await lbb.waitForPublished(fields.commit_seq); ``` ### Python ```python import os import time from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") lbb.ontology.evolve({ "ops": [ {"op": "add_property", "name": "severity", "value_type": "keyword"}, {"op": "add_property", "name": "opened_at", "value_type": "date_time"}, ], }) fields = lbb.graph("support").facts.create({ "entity_properties": [ {"type": "Ticket", "key": "4821", "name": "login fails after 3.1", "properties": {"severity": "high", "opened_at": "2026-09-02T09:15:00Z"}}, {"type": "Ticket", "key": "4822", "name": "invoice PDF is blank", "properties": {"severity": "medium", "opened_at": "2026-09-03T14:40:00Z"}}, {"type": "Ticket", "key": "4823", "name": "CSV export times out", "properties": {"opened_at": "2026-09-04T08:05:00Z"}}, ], }, idempotency_key="sparql-guide-fields-1") lbb.wait_for_published(fields["commit_seq"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"ops": [ {"op": "add_property", "name": "severity", "value_type": "keyword"}, {"op": "add_property", "name": "opened_at", "value_type": "date_time"} ]}' curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: sparql-guide-fields-1" \ -d '{"entity_properties": [ {"type": "Ticket", "key": "4821", "name": "login fails after 3.1", "properties": {"severity": "high", "opened_at": "2026-09-02T09:15:00Z"}}, {"type": "Ticket", "key": "4822", "name": "invoice PDF is blank", "properties": {"severity": "medium", "opened_at": "2026-09-03T14:40:00Z"}}, {"type": "Ticket", "key": "4823", "name": "CSV export times out", "properties": {"opened_at": "2026-09-04T08:05:00Z"}} ]}' until curl -sS "$LBB_URL/v1/graph/publication-status?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done ``` ### SPARQL basics SPARQL is the W3C query language for graphs. A query is a list of patterns. Each pattern names a record, then a link or field, then a record or value. Names that start with `?` are variables. The query returns every set of values that fits all the patterns at once. Each type, link and field you declare has a global name, an IRI. A query shortens IRIs with prefixes: ```sparql PREFIX dclass: PREFIX dr: PREFIX dp: PREFIX rdfs: PREFIX xsd: ``` | You declared | In SPARQL | | --- | --- | | Type `Ticket` | `dclass:ticket` | | Link `REPORTED_AS` | `dr:reported_as` | | Field `severity` | `dp:severity` | | A record's `name` | `rdfs:label` | The local names are lowercase. Each query below needs the prefix lines above. The examples leave them out. This query lists each ticket with its fields. `a` means "has type", and `;` starts another pattern about the same record. `OPTIONAL` keeps ticket 4823, which has no severity: ```sparql SELECT ?ticket ?severity ?opened WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dp:opened_at ?opened . OPTIONAL { ?t dp:severity ?severity } } ORDER BY ?opened ``` | ticket | severity | opened | | --- | --- | --- | | login fails after 3.1 | high | 2026-09-02T09:15:00+00:00 | | invoice PDF is blank | medium | 2026-09-03T14:40:00+00:00 | | CSV export times out | | 2026-09-04T08:05:00+00:00 | A path follows several links in one pattern. `dr:reported_as/dr:fixed_in` goes from a ticket to its bug, then to the release. `GROUP BY` and `COUNT` count the tickets per release: ```sparql SELECT ?release (COUNT(?t) AS ?tickets) WHERE { ?t a dclass:ticket ; dr:reported_as/dr:fixed_in ?r . ?r rdfs:label ?release . } GROUP BY ?release ORDER BY ?release ``` Release 3.1.1 and Release 3.2 each count one ticket. Ticket 4823 has no release, so it drops out. A `+` after a link follows it one or more times, as in `dr:fixed_in+`. A `^` before a link follows it backwards. `FILTER NOT EXISTS` keeps the rows that lack a pattern. This query finds the bugs with no fix yet. It returns `export runs on one thread`: ```sparql SELECT ?bug WHERE { ?b a dclass:bug ; rdfs:label ?bug . FILTER NOT EXISTS { ?b dr:fixed_in ?r } } ``` `FILTER` also compares values, as in `FILTER(?opened >= "2026-09-03T00:00:00Z"^^xsd:dateTime)`. The `^^` suffix gives a value its datatype. Read [FILTER comparisons and datatypes](#filter-comparisons-and-datatypes) before you compare dates. [Core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql) maps Cypher patterns to SPARQL. ### Structured query `POST /v1/query/sparql` takes the query as JSON. Use it when app code builds a query from parts, such as a filter form. You write no query text and no IRIs. Link names are case-insensitive. The structured form covers a subset of SPARQL. Each pattern links two records, and each variable binds a record. Fields appear in filters, group keys and aggregates. For paths, `OPTIONAL`, `UNION` or negation, use [SPARQL text](#sparql-11-text). This body counts the tickets per severity: ```json { "patterns": [{ "subject": { "var": "t" }, "predicate": "REPORTED_AS", "object": { "var": "b" } }], "group_keys": [{ "property": { "var": "t", "field": "severity", "as": "severity" } }], "aggregates": [{ "func": "count", "as": "tickets" }], "order_by": [{ "var": "severity" }] } ``` The response has one entry in `groups` per severity, with `value_keys.severity` and `aggregates.tickets`. Ticket 4823 has no severity, so its group key is `null`. | Field | Meaning | | --- | --- | | `patterns` | The links to match: `subject`, `predicate` (a link name) and `object`. A side is a variable, `{ "var": "t" }`, or one record, `{ "entity": { "entity_id": "…" } }`. | | `filters` | Conditions on each row, joined with AND. | | `group_by` | Variables whose records form the groups. They come back in `groups[].keys`. | | `group_keys` | Group by a field, `{ "property": { "var", "field", "as" } }`, or by a date bucket, `{ "date_bucket": { "var", "field", "granularity", "as" } }`. `granularity` is `year`, `month`, `week`, `day` or `hour`, in UTC. The keys come back in `groups[].value_keys`. | | `aggregates` | `count`, `sum`, `avg`, `min` or `max`, each with an `as` name. `operand` names a number, such as `{ "property": { "var", "field" } }`. `count` needs none, and takes `distinct`. | | `having` | Conditions on the groups. | | `order_by` | Sort keys: `{ "var": "…", "descending": false }`. | | `select`, `distinct` | The variables to return, and whether to drop duplicate rows. Without `select`, every variable comes back. | | `ask` | `true` returns only `boolean`: whether any row matches. | | `limit`, `offset` | Page the rows. `row_page` reports the page. | | `max_solutions` | Cap the rows. Past the cap, the response sets `truncated: true`. | | `max_object_reads`, `max_fetched_bytes` | Accepted. The server's own read budget applies, so `truncated_by_read_budget` stays `false`. | | `consistency`, `min_indexed_seq`, `as_of_commit_seq` | Which commit to read. See [strong and eventual consistency](#strong-and-eventual-consistency). | | `reason` | `true` returns 400. Stored rules already run when a commit is published. | | `profile` | `true` adds a `profile` with the time, reads and join order of this request. See [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). | | Filter part | Shape | | --- | --- | | Comparison | `{ "compare": { "op", "left", "right" } }`. `op` is `eq`, `ne`, `lt`, `le`, `gt` or `ge`. | | Logic | `{ "and": […] }`, `{ "or": […] }` or `{ "not": {…} }` | | Operand | A record `{ "var": "t" }`, a field `{ "property": { "var", "field" } }`, or a value `{ "value": { "": … } }` | | Value types | `str`, `i64`, `f64`, `bool`, `date_time` or `entity` | A comparison with an unbound or mismatched operand is false. `GET /v1/ontology` lists the field names in `property_defs`. The response holds `vars`, and `solutions` for rows or `groups` for aggregates. A row binds each variable to a record with its `id`, `name` and `type`. `boolean` answers `ask`, and `snapshot` names the commit read. The SDKs add `filterByAttributes` / `filter_by_attributes`. It takes the patterns plus simple field comparisons: ### TypeScript ```ts // Tickets opened on or after 3 September, with the release that fixed them. const recent = await lbb.entities.filterByAttributes({ patterns: [ { subject: { var: "t" }, predicate: "REPORTED_AS", object: { var: "b" } }, { subject: { var: "b" }, predicate: "FIXED_IN", object: { var: "r" } }, ], where: [{ var: "t", field: "opened_at", op: "ge", value: { dateTime: "2026-09-03T00:00:00Z" } }], select: ["t", "r"], }); for (const row of recent.solutions) { console.log(row.bindings.t.name, "->", row.bindings.r.name); } // The full structured request: tickets per severity. const bySeverity = await lbb.sparql({ patterns: [{ subject: { var: "t" }, predicate: "REPORTED_AS", object: { var: "b" } }], group_keys: [{ property: { var: "t", field: "severity", as: "severity" } }], aggregates: [{ func: "count", as: "tickets" }], }); console.log(bySeverity.groups.length, "groups"); ``` ### Python ```python # Tickets opened on or after 3 September, with the release that fixed them. recent = lbb.entities.filter_by_attributes( patterns=[ {"subject": {"var": "t"}, "predicate": "REPORTED_AS", "object": {"var": "b"}}, {"subject": {"var": "b"}, "predicate": "FIXED_IN", "object": {"var": "r"}}, ], where=[{"var": "t", "field": "opened_at", "op": "ge", "value": {"date_time": "2026-09-03T00:00:00Z"}}], select=["t", "r"], ) for row in recent["solutions"]: print(row["bindings"]["t"]["name"], "->", row["bindings"]["r"]["name"]) # The full structured request: tickets per severity. by_severity = lbb.sparql_select({ "patterns": [{"subject": {"var": "t"}, "predicate": "REPORTED_AS", "object": {"var": "b"}}], "group_keys": [{"property": {"var": "t", "field": "severity", "as": "severity"}}], "aggregates": [{"func": "count", "as": "tickets"}], }) print(len(by_severity["groups"]), "groups") ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"patterns": [ {"subject": {"var": "t"}, "predicate": "REPORTED_AS", "object": {"var": "b"}}, {"subject": {"var": "b"}, "predicate": "FIXED_IN", "object": {"var": "r"}}], "filters": [{"compare": {"op": "ge", "left": {"property": {"var": "t", "field": "opened_at"}}, "right": {"value": {"date_time": "2026-09-03T00:00:00Z"}}}}], "select": ["t", "r"]}' \ | jq -r '.solutions[] | "\(.bindings.t.name) -> \(.bindings.r.name)"' ``` The filter returns one row, `invoice PDF is blank -> Release 3.1.1`. Ticket 4823 is newer too, but no release fixes its bug yet. The severity query returns three groups. ### SPARQL 1.1 text `POST /v1/query/sparql-text` runs a SPARQL 1.1 `SELECT` or `ASK` query. The response carries the SPARQL Results JSON document as a string, in `results`. `CONSTRUCT` and `DESCRIBE` return RDF, so send them to [`/sparql`](#external-sparql-tools). | Body field | Meaning | | --- | --- | | `query` | The query text. | | `entailment` | `none` (the default), `subclass`, `rdfs` or `owl`. | | `as_of_commit_seq` | Run the query on the graph as it was at this commit. | | `limit`, `offset` | Page the rows after the query runs. `row_page` reports the page. | | `cursor` | Page a long ordered listing on one commit. | | `request` | The user's words behind the query. The server records an eval trace and returns its `trace_id`. | | `reason` | `true` returns 400. Stored rules already run when a commit is published. | | `profile` | `true` adds a `profile` with the time, reads and join order of this request. See [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). | `consistency` and `min_indexed_seq` go on the URL. The SDKs parse the result into rows: ### TypeScript ```ts const query = ` PREFIX dclass: PREFIX dp: PREFIX rdfs: SELECT ?ticket ?severity WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dp:severity ?severity . } ORDER BY ?ticket`; const { vars, rows } = await lbb.sparqlRows({ query }); for (const row of rows) console.log(row.ticket, row.severity); const anyBug = await lbb.sparqlRows({ query: "ASK { ?b a }" }); console.log(anyBug.boolean); ``` ### Python ```python query = """ PREFIX dclass: PREFIX dp: PREFIX rdfs: SELECT ?ticket ?severity WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dp:severity ?severity . } ORDER BY ?ticket""" results = lbb.sparql(query) for row in results: print(row["ticket"], row["severity"]) print(lbb.sparql("ASK { ?b a }").boolean) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"query": "SELECT ?ticket ?severity WHERE { ?t a ; ?ticket ; ?severity } ORDER BY ?ticket"}' \ | jq -r '.results | fromjson | .results.bindings[] | "\(.ticket.value) \(.severity.value)"' ``` ### MCP ```text lbb_query mode=sparql graph=support query="PREFIX dclass: PREFIX dp: PREFIX rdfs: SELECT ?ticket ?severity WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dp:severity ?severity . } ORDER BY ?ticket" ``` Each returns two rows. `invoice PDF is blank` has `medium`, and `login fails after 3.1` has `high`. The ASK query returns `true`. :::caution[Lowercase local names] Types, links and fields you declare have IRIs of the form ``. The local name is **lowercase**. An uppercase one, such as `<…/r/REPORTED_AS>`, is another IRI. It matches nothing and reports no error. The structured query is case-insensitive. The MCP `sparql` mode lowercases these local names and adds a note that says so. Data loaded through [RDF import](https://docs.littlebigbrain.com/guides/load-rdf/) keeps its **source IRIs**, so query those as they are. ::: #### Read a link's evidence Each piece of evidence on a link is one statement node. It uses the standard RDF reification terms (`rdf:subject`, `rdf:predicate`, `rdf:object`) and LBB terms in `https://littlebigbrain.com/ns#`: `lbb:evidenceText`, `lbb:evidenceSource`, `lbb:observation`, `lbb:confidence`, `lbb:commit`, and `lbb:evidenceRegion` for a page region. A link without evidence still matches the outer pattern: ```sparql PREFIX rdf: PREFIX lbb: SELECT ?bug ?text ?source WHERE { ?ticket ?bug . OPTIONAL { ?st rdf:subject ?ticket ; rdf:predicate ; rdf:object ?bug ; lbb:evidenceText ?text ; lbb:evidenceSource ?source . } } ``` The text keeps at most 4,096 bytes; a cut text carries `lbb:evidenceTextTruncated true`. A write may not name a statement node or a predicate in the `lbb:` namespace: it returns `400 reserved_namespace`. Evidence that a document workflow reads from a file names the file and the place in it. Its `lbb:evidenceSource` is the file's `Document` IRI with a fragment, such as `https://littlebigbrain.com/e/41ab…#page=3` for page 3 of a PDF. `IRI(STRBEFORE(?source, "#"))` is the `Document`, whose `p:name` is the file's name. #### Read a link's properties A link's own fields are a statement node too. The values use `https://littlebigbrain.com/ep/`, with the field names of your schema. They are typed like record fields. `p:status` matches records only, and `ep:status` matches links only. ```sparql PREFIX rdf: PREFIX ep: SELECT ?ticket ?bug ?status WHERE { ?ticket ?bug . ?st rdf:subject ?ticket ; rdf:predicate ; rdf:object ?bug ; ep:status ?status . } ``` In `append` mode each statement of a link with properties is its own node. Order them by `lbb:commit` to pick the newest. A write may not name a predicate in the `ep:` namespace: it returns `400 reserved_namespace`. #### Reasoning, history and paging A query reads the facts as written by default (`entailment: "none"`). The values `subclass`, `rdfs` and `owl` apply those rules at query time. They take the schema from the commit the query reads. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/). `as_of_commit_seq` runs the query on the graph as it was at that commit. The response echoes the pin in `snapshot.as_of_commit_seq`. A pin past the latest commit returns 400. A commit that is no longer retained returns 400 `historical_generation_unavailable`. The server never answers from a nearby commit instead. [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/) covers retention. Only `as_of_commit_seq` pins a read. Valid time is not queryable. `/sparql?as_of_valid_time=` returns 400 `historical_generation_unavailable`. The text route rejects an `as_of_valid_time` field with 400. `cursor` pages a long listing on one commit. Send `""` for the first page. Then send each `next_cursor` back with the same query. The query must order by `LCASE(STR(?label)) STR(?id)`. It needs one `LIMIT` from 1 to 10,000, and no `OFFSET`. Other query shapes return 400 `invalid_pagination_cursor`. Page those with `limit` and `offset`. A cursor whose commit is no longer retained returns 410 `pagination_cursor_expired`. Then start again from the first page. #### Strong and eventual consistency A write becomes part of the published data a second or two after its commit. The `consistency` option sets which data a read covers: | Mode | What it reads | Use it when | | --- | --- | --- | | `eventual` (default) | The latest published commit. | You want the fast default, or you record the commit you read. | | `strong` | The latest published commit plus every newer write. It is exact right after each acknowledged write. | You must read your own write at once. | | Surface | Where you set it | Default | | --- | --- | --- | | Structured `POST /v1/query/sparql` | `consistency` in the JSON body | `eventual` | | Text `POST /v1/query/sparql-text` | `?consistency=` on the URL | `eventual` | | Native `/sparql` | `?consistency=` on the URL. An unknown value returns 400. | `eventual` | An eventual response names the commit it read in `snapshot.served_at_seq`. When newer writes exist, it also sets `stale: true` and `stale_reason: "eventual_consistency"`. A strong text response has no `snapshot` block. To read one write, pass its `commit_seq` as `min_indexed_seq`. It is a body field on the structured route and a URL parameter elsewhere. A floor with no `consistency` makes the read strong. With `consistency=eventual`, a floor returns a retryable 429 `read_your_writes_pending` until publication covers it. Retry after `Retry-After`, or call `waitForPublished` / `wait_for_published` first. A floor above the newest commit returns the same 429 in every mode. Reasoning works the same in both modes. It uses the schema of the latest published commit. In the console **Query** view, a selector sets the mode per run. The default is strong, so a query sees your latest writes. An eventual result shows an "as of @N" badge. When a strong run still returns `strong_read_pending` after its retries, the view polls publication and runs the query again. A button runs the eventual read once. #### Profile a query in the console In **Query**, turn on **Profile** and run the query. A profile shows under the results: - The total time, and the time of each stage as a bar. - The blocks and bytes the query read, and the cache hits and misses. - Whether the trigram index served a text filter, and the reason when it did not. - The operators that ran, with their input and output rows. - The join order: the patterns in the order the planner chose, with its estimated row count for each. A profiled run skips the result cache, so it always executes. The fields are described in [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). **Query planner**, under Graph, shows what the planner reads for the graph: the triple, subject and object counts of each predicate, and the statistics and indexes of the published generation. It lists the profiles you ran in the browser tab. #### Query limits and errors Each query has its own read budget, deadline and cancellation. A query that hits its limit does not affect other queries on the graph. Past the read budget or the deadline, a query fails with a typed error. It never returns part of a result as a success. Joins check cancellation as they build their results and stop at the existing intermediate-row limit while those rows form. The read budget counts every byte a query reads from the index. Bytes served from cache count too. The budget does not measure network transfer or billed storage. | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `query_deadline_exceeded` | 503 | The query ran past its 25-second deadline. | Retry after `Retry-After`, or narrow the query. | | `scan_too_wide` | 400 | The query needs more index reads than one request may make. | Narrow the pattern, such as with a type or a fixed record. | | `result_too_large` | 400 | The result is over 1,000,000 rows or 256 MiB, or one join step builds over 16,000,000 rows. | Add `LIMIT`, tighten the pattern, or page. Join patterns through a shared variable. | | `strong_read_pending` | 429 | The exact view for a strong read is not ready. | Retry after `Retry-After`, or read eventual. | | `read_your_writes_pending` | 429 | Publication does not cover the `min_indexed_seq` floor yet, or the floor is above the newest commit. | Retry after `Retry-After`. | | `ingest_busy` | 429 | An eventual read found nothing published yet. | Retry after `Retry-After`. | | `historical_generation_unavailable` | 400 | The pinned commit is no longer retained. | Read a retained commit. | | `pagination_cursor_expired` | 410 | The cursor's commit is no longer retained. | Start from the first page. | A query that nests too deeply fails with `400 invalid_request`. The message starts with `query too deeply nested`. A retry of the same query fails again. Brackets nest at most 48 levels deep. Graph patterns nest at most 128 levels, and expressions at most 64. A group, a `UNION` or an `||` list holds at most 2,048 items. Use `VALUES` or `IN` for long lists of alternatives. An error body is `{"error": {…}}` with `type`, `code`, `message`, `param`, `request_id` and `doc_url`. A retryable error also has `retryable: true` and `retry_after_seconds`. #### External SPARQL tools Each stack serves the **SPARQL 1.1 Protocol** at `/sparql`. YASGUI, Protégé and RDFLib's `SPARQLWrapper` connect to it directly. They send the stack API key as a bearer token. The endpoint accepts three request forms: - `GET /sparql?query=` - `POST /sparql` with a form body, `query=…` - `POST /sparql` with the query as an `application/sparql-query` body `SELECT` and `ASK` return SPARQL Results JSON, XML, CSV or TSV, chosen by `Accept`. `CONSTRUCT` and `DESCRIBE` return Turtle or N-Triples. `?format=` overrides `Accept` with `json`, `xml`, `csv`, `tsv`, `turtle` or `nt`. | URL parameter | Meaning | | --- | --- | | `consistency`, `min_indexed_seq` | Which commit to read, as above. | | `as_of_commit_seq` | Run the query at this commit. | | `entailment` | `none`, `subclass`, `rdfs` or `owl`. An unknown value returns 400. | | `reason` | `true` returns 400, as on the text route. | | `max_graph_triples` | Lower the triple cap for one `CONSTRUCT` or `DESCRIBE`. | | `format` | Override `Accept`. | A `CONSTRUCT` or `DESCRIBE` answer holds at most 1,000,000 triples by default. A larger answer returns 400 before the server sends any byte. So a 200 answer is always complete. `max_graph_triples` can lower the cap, and a value above the cap returns 400. This request returns the ticket names as CSV: ```sh curl --fail-with-body -sS -G "$LBB_URL/sparql?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Accept: text/csv" \ --data-urlencode "query= PREFIX dclass: PREFIX rdfs: SELECT ?ticket WHERE { ?t a dclass:ticket ; rdfs:label ?ticket } ORDER BY ?ticket" ``` The same endpoint accepts SPARQL Update, as an `update=` form field or an `application/sparql-update` body. So does `/update`. Each graph serves one default RDF graph. A write into a named graph returns 400, so keep datasets apart with separate graphs. A `CONSTRUCT` query over the whole graph returns it as Turtle. The [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/#native-sparql-11-protocol) has the full protocol reference. #### FILTER comparisons and datatypes In SPARQL, a comparison that is a *type error* drops the row and reports no error. A comparison of datatypes the engine does not order returns an empty answer. The engine orders these values in `<`, `<=`, `>`, `>=`, `=`, `MIN`, `MAX` and `ORDER BY`: - **Numbers** across the XSD numeric types, **strings** in code point order, and **booleans**. - **Dates and times of one datatype**: `xsd:dateTime`, `xsd:date`, `xsd:time` and the Gregorian types. These are `gYear`, `gYearMonth`, `gMonthDay`, `gMonth` and `gDay`. - **Durations of one datatype**: `xsd:yearMonthDuration` and `xsd:dayTimeDuration` in a total order. `xsd:duration` uses the XSD partial order. The Gregorian types go beyond the SPARQL 1.1 operator table. They use the XSD 1.1 order, because real data often types years and months this way. In the `xsd:duration` partial order, some pairs stay type errors, such as `P30D` and `P1M`. A value without a timezone compared with a zoned value takes UTC, as SPARQL prescribes. A stored `"2020-06-01T12:00:00"^^xsd:dateTime` passes `FILTER(?when > "2020-06-01T11:59:59Z"^^xsd:dateTime)`. It also equals `"2020-06-01T14:00:00+02:00"^^xsd:dateTime`. Pairs of different datatypes stay type errors. Examples are `xsd:date` with `xsd:dateTime`, and `gYear` with `gYearMonth`. So give a bound the **same datatype** as the data. The `opened_at` field is an `xsd:dateTime`: ```sparql # Two tickets: 4822 and 4823. SELECT ?ticket WHERE { ?t rdfs:label ?ticket ; dp:opened_at ?opened . FILTER(?opened >= "2026-09-03T00:00:00Z"^^xsd:dateTime) } # No rows: an xsd:date bound against xsd:dateTime values is a type error. SELECT ?ticket WHERE { ?t rdfs:label ?ticket ; dp:opened_at ?opened . FILTER(?opened >= "2026-09-03"^^xsd:date) } ``` The response does not count the rows that type errors dropped. The server records the count as `filter_type_errors` in its query log. It also counts them in the metric `lbb_sparql_filter_type_errors_total`. The date functions follow the same rule: | Function | Accepts | | --- | --- | | `YEAR()` | `xsd:dateTime`, `xsd:date`, `xsd:gYear`, `xsd:gYearMonth` | | `MONTH()` | `xsd:dateTime`, `xsd:date`, `xsd:gYearMonth` | | `DAY()` | `xsd:dateTime`, `xsd:date` | So `SUM(YEAR(?o))` over values typed `gYear` returns the sum. DBLP types years this way. A missing part is a type error: `MONTH()` on a `gYear`, or `DAY()` on a `gYearMonth`. To compare a string with a date, cast the string first. The date casts follow the XPath casting table: | Cast | Accepts | | --- | --- | | `xsd:dateTime()` | a string, `xsd:dateTime`, `xsd:date` | | `xsd:date()` | a string, `xsd:date`, `xsd:dateTime` | | `xsd:time()` | a string, `xsd:time`, `xsd:dateTime` | A date casts to the start of its day. An invalid string, such as `"2026-13-01"`, is a type error, so a `BIND` of it stays unbound. A `SUM`, `AVG`, `MIN` or `MAX` whose every input is a type error comes back unbound. A group with no rows still sums to 0, as the standard says. ### Published SHACL conformance SHACL is the W3C standard for rules about your records. A rule set is called a set of **shapes**. A shape selects records, such as every ticket, and states what each one must have. You publish shapes for a graph. The platform then checks every record in the background and publishes a **conformance report**. In `reject` mode it also checks each write and refuses one that breaks a rule. These shapes state three rules for tickets. Each ticket has a severity. The severity is `low`, `medium` or `high`. Each ticket names the bug it was reported as: ```turtle @prefix sh: . @prefix dclass: . @prefix dr: . @prefix dp: . a sh:NodeShape ; sh:targetClass dclass:ticket ; sh:property [ sh:path dp:severity ; sh:minCount 1 ; sh:message "A ticket has a severity" ] ; sh:property [ sh:path dp:severity ; sh:in ( "low" "medium" "high" ) ; sh:message "Severity {$value} is not low, medium or high" ] ; sh:property [ sh:path dr:reported_as ; sh:minCount 1 ; sh:message "A ticket names the bug it was reported as" ] . ``` `sh:targetClass` selects the records of a type. `sh:path` names the field or link that a rule checks. `sh:minCount` and `sh:in` state the rules, and `sh:message` gives each one a message. Save the shapes as `shapes.ttl`. Publish them in `warn` mode, then read the report: ### TypeScript ```ts import { readFileSync } from "node:fs"; const shapesTurtle = readFileSync("shapes.ttl", "utf8"); const shapes = { source: shapesTurtle, format: "turtle" }; const published = await lbb.schema.publish({ desired_mode: "warn", shapes }); let report = await lbb.ontology.conformance({ consistency: "strong" }); console.log(published.shapes_version, report.conforms, report.result_count); for (const result of report.results ?? []) console.log(result.focus_node, result.message); ``` ### Python ```python with open("shapes.ttl") as f: shapes_turtle = f.read() shapes = {"source": shapes_turtle, "format": "turtle"} published = lbb.schema.publish({"desired_mode": "warn", "shapes": shapes}) report = lbb.ontology.conformance(consistency="strong") print(published["shapes_version"], report.conforms, report.result_count) for result in report.results or []: print(result.focus_node, result.message) ``` ### cURL ```sh jq -n --rawfile src shapes.ttl '{desired_mode: "warn", shapes: {format: "turtle", source: $src}}' \ | curl --fail-with-body -sS -X POST "$LBB_URL/v1/schema/publish?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ --data-binary @- curl --retry 10 -fsS \ "$LBB_URL/v1/ontology/conformance?graph=support&consistency=strong" \ -H "Authorization: Bearer $LBB_API_KEY" ``` ### MCP ```text lbb_inspect action=ontology_conformance graph=support ``` The SDKs and `curl --retry` repeat the strong read until the new report exists. The report does not conform. It has one result, for ticket 4823: ```json { "conforms": false, "ontology_version": 3, "result_count": 1, "results": [ { "component": "http://www.w3.org/ns/shacl#MinCountConstraintComponent", "focus_node": "", "message": "A ticket has a severity", "result_path": "", "severity": "violation", "source_node_shape": "", "source_shape": "_:d0ebc6f9ff3a2bc554bf310bcf643615", "value": null } ], "shapes_version": 1, "validated_at_seq": 2 } ``` #### Read the conformance report `GET /v1/ontology/conformance` returns the report. Reading it never runs a validation. A publish changes metadata only. Its response means the shapes are active. It says nothing about the current data. The default read returns the latest report. While new commits are being validated, that report can be for an earlier commit. `validated_at_seq` names the commit it checked. Add `consistency=strong` to wait for the report of the latest commit. | Field | Meaning | | --- | --- | | `conforms` | `true` when no record breaks a rule. | | `result_count` | The exact number of results. | | `validated_at_seq` | The commit the report checked. | | `ontology_version`, `shapes_version` | The schema and shapes the report checked. | | `results` | One entry per result, up to `?limit=` (default 200, at most 2,000). | | `truncated` | `true` when `results` holds fewer entries than `result_count`. | Each result has a `focus_node` (the record) and a `component` (the rule that failed). It also has `result_path`, `value`, `severity` and `message`. `details` lists the inner results of a nested failure. `source_node_shape` is the shape that selected the record. `source_shape` is the rule inside it, often a blank node. An eventual read returns the report for the latest published commit. A strong read returns 429 `strong_read_pending` until a report for the latest commit exists. Check `shapes_version` and `validated_at_seq` before you act on a report. An older report can describe older shapes. Validation does not delay queries. Suppose you evolve the ontology while shapes are active. The platform then restates the same shapes for the new ontology version. No reader sees a new ontology with stale shapes. #### Supported SHACL The platform checks common rules with fast scans of the index. These rules check one direct link or field: counts, datatypes, classes and similar checks. The platform checks `sh:or` over such rules the same way. Other rules use the general validator. Examples are SPARQL constraints, recursive paths, nested logical rules and closed shapes. That validator has a memory budget for background work. A validation that does not fit fails visibly. It never exceeds the budget or skips the check. Each result keeps its severity, source shape and message. A publish parses every shape and returns 400 for what the validator cannot run: - a malformed SPARQL constraint, - an `sh:ask` validator, - a SPARQL-based target (`sh:target`), - an `sh:path` expression the engine does not recognize. A shape set that publishes can run. The current data may still break it. SPARQL constraints declare prefixes the standard way. `sh:prefixes` points at `sh:declare` entries, which apply to the `sh:select` text. `sh:message` accepts templates. `{$this}` is the record, `{$value}` the offending value and `{$path}` the path. A value that fails an `sh:node` shape lists the inner failure in `details`. The publish response says how the shapes will be checked. `write_enforcement` has a `timing` and lists each root shape with `write_enforceable`: | `timing` | When | | --- | --- | | `write_time` | `reject` mode. Each write checks the records it can affect, at a cost that follows the size of the write. | | `write_time_full_scan` | `reject` mode on a graph written as JSON records, with a rule that has no bounded set of affected records. Each commit checks the whole graph. | | `after_publication` | `warn` or `off` mode. Writes are accepted, and the conformance report lists the results. | An `sh:sparql` constraint or a recursive shape reference has no bounded set of affected records. Its shape shows `write_enforceable: false`. A `blockers` entry names the shape and the constraint. #### Choose an enforcement mode Each publish sets a `desired_mode`. The same source with another mode creates a new `shapes_version`. So to change the mode, send the shapes again. | Mode | Records already stored | Later writes | | --- | --- | --- | | `off` | Kept as they are. The conformance report is still produced. | Not checked. | | `warn` | Kept as they are, and listed in the conformance report. | Accepted. A JSON commit reports results in `commit.schema_validation`. SPARQL Update and Graph Store writes return no body, so only the report lists theirs. | | `reject` | Kept and listed in the report. A publish never deletes or rewrites them. | A write that leaves a rule broken returns 400. Nothing from it is stored. | `POST /v1/schema/publish?dry_run=true` checks a publish without applying it. It parses the shapes and returns `write_enforcement` with `activated: false`. It does not validate the stored records. Continue the example. Fix ticket 4823, wait for a clean report, then publish the same shapes in `reject` mode: ### TypeScript ```ts const repair = await lbb.graph("support").facts.create( { entity_properties: [{ type: "Ticket", key: "4823", name: "CSV export times out", properties: { severity: "low" } }] }, { idempotencyKey: "sparql-guide-repair-4823" }, ); do { await new Promise((resolve) => setTimeout(resolve, 1_000)); report = await lbb.ontology.conformance({ consistency: "strong" }); } while ((report.validated_at_seq ?? 0) < repair.commit_seq); if (report.conforms) { await lbb.schema.publish({ desired_mode: "reject", shapes }); } ``` ### Python ```python repair = lbb.graph("support").facts.create({ "entity_properties": [{"type": "Ticket", "key": "4823", "name": "CSV export times out", "properties": {"severity": "low"}}], }, idempotency_key="sparql-guide-repair-4823") while True: time.sleep(1) report = lbb.ontology.conformance(consistency="strong") if report.validated_at_seq and report.validated_at_seq.root >= repair["commit_seq"]: break if report.conforms: lbb.schema.publish({"desired_mode": "reject", "shapes": shapes}) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: sparql-guide-repair-4823" \ -d '{"entity_properties": [{"type": "Ticket", "key": "4823", "name": "CSV export times out", "properties": {"severity": "low"}}]}' sleep 2 curl --retry 10 -fsS \ "$LBB_URL/v1/ontology/conformance?graph=support&consistency=strong" \ -H "Authorization: Bearer $LBB_API_KEY" | jq '{conforms, validated_at_seq}' jq -n --rawfile src shapes.ttl '{desired_mode: "reject", shapes: {format: "turtle", source: $src}}' \ | curl --fail-with-body -sS -X POST "$LBB_URL/v1/schema/publish?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ --data-binary @- | jq '.write_enforcement.timing' ``` The report now conforms at the repair commit. The `reject` publish reports `timing: "write_time"`. #### Tighten validation without stopping ingestion When new shapes do not fit the records in a graph, change it in steps: 1. Publish the new shapes with `desired_mode: "warn"`. 2. Wait for the strong report for that `shapes_version`, and read its results. 3. Add, update or retract the affected data while writes are still accepted. 4. Wait until the strong report says `conforms: true` at the latest commit. 5. Publish the same shapes source with `desired_mode: "reject"`. 6. Treat a later 400 from a write as feedback to act on. A rule such as `sh:minCount` needs care. Say a record and its required link arrive in two commits. In `reject` mode, the first commit fails. Put facts that require each other in one write, or load them under `warn` first. Publishing `reject` onto data that breaks a rule keeps the data safe. Old records stay readable, and a refused write stores nothing. It can block repairs, though. With `write_time` timing, a write checks only the records it can affect. Unrelated writes go through. A write that touches a broken record must leave it valid. With `write_time_full_scan` timing, each commit checks the whole graph. One existing violation then refuses every write, until one write repairs all of them. `/v1/schema/publish` has no dry run that checks stored data against new shapes. `confirm_restrictive` does not add one. #### Rejected writes and dry runs In `reject` mode, a write that breaks a rule returns 400 and stores nothing. The message quotes the first violations: ```text schema validation failed in reject mode with 1 SHACL violation(s): http://www.w3.org/ns/shacl#InConstraintComponent on '' path '': Severity "urgent" is not low, medium or high. Preflight with dry_run=true to get the full structured report (schema_validation.results) ``` A dry run returns the same check as data and stores nothing. Send the write to `POST /v1/graph/commit?dry_run=true`, or call `commitDryRun` / `commit_dry_run`. The response has `schema_validation.conforms` and `schema_validation.results`. ### TypeScript ```ts const newTicket = { type: "Ticket", key: "4824", name: "password reset email never arrives" }; const preflight = await lbb.commitDryRun({ triplets: [{ source: newTicket, relation: "REPORTED_AS", target: { type: "Bug", key: "AUTH-113", name: "mail queue drops resets" } }], entity_properties: [{ ...newTicket, properties: { severity: "urgent" } }], }); for (const result of preflight.schema_validation?.results ?? []) { console.log(result.component, result.message); } ``` ### Python ```python new_ticket = {"type": "Ticket", "key": "4824", "name": "password reset email never arrives"} preflight = lbb.commit_dry_run({ "triplets": [{"source": new_ticket, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "AUTH-113", "name": "mail queue drops resets"}}], "entity_properties": [{**new_ticket, "properties": {"severity": "urgent"}}], }) for result in preflight["schema_validation"]["results"]: print(result["component"], result["message"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support&dry_run=true" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"triplets": [{"source": {"type": "Ticket", "key": "4824", "name": "password reset email never arrives"}, "relation": "REPORTED_AS", "target": {"type": "Bug", "key": "AUTH-113", "name": "mail queue drops resets"}}], "entity_properties": [{"type": "Ticket", "key": "4824", "name": "password reset email never arrives", "properties": {"severity": "urgent"}}]}' \ | jq '.schema_validation' ``` The result is one `sh:InConstraintComponent` violation. Its message is `Severity "urgent" is not low, medium or high`. An agent can read the list, correct its write and send it again. The check covers what a write removes. A commit whose `remove_fields` drops a required field returns 400. `POST /v1/graph/retract` is not checked. A retraction that breaks a rule is stored, and the conformance report lists it. RDF import, SPARQL Update and Graph Store writes keep the source RDF terms. So a shape with `sh:targetClass ` checks the records of that class by their own IRIs. It needs no internal ids. #### Write-gate limits A graph whose first write is RDF checks each `reject`-mode write before it applies it. The check reads the records the write can affect, including records the write does not name. Its cost follows the size of the write. See [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/). | Refusal | When | What to do | | --- | --- | --- | | `400 shapes_not_write_enforceable` | A `reject` publish or a write meets an `sh:sparql` constraint or a recursive shape reference. The active shapes stay unchanged. | Publish the set in `warn` mode and read the report, or remove the constraint. | | `400 shacl_write_validation_budget` | One write affects more than 250,000 records, or needs more than 2,000,000 reverse lookups. | Split the write, or load it under `warn`. | | `429 strong_read_pending` | More than 64 MiB of statements wait for publication, or a read behind the check failed. | Retry after `Retry-After`. | A graph written as JSON records never returns `shapes_not_write_enforceable`. There, a rule with no bounded set of affected records gets `write_time_full_scan` timing. ### Why RDF & SHACL suit AI agents An agent writes facts one claim at a time, and some claims are wrong. RDF and SHACL fit that pattern: - **RDF stores one fact at a time.** A triple is a subject, a predicate and an object. A new kind of link needs no table design and no migration. - **Global names merge facts.** A record has one IRI everywhere. Facts from two agents, or from turn 1 and turn 900, attach to the same record. - **A missing fact means unknown.** A new fact does not rewrite others. An agent can record what it learns before the schema is complete. - **Values keep their types.** Numbers, dates and booleans compare and aggregate as values, without string matching. - **SHACL checks a write before it is stored.** In `reject` mode, a write that breaks a shape fails. The result names the record, the rule and a message. - **Reasoning is repeatable.** `entailment` derives facts from the schema the same way on every run. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/). - **Schema changes have a preview.** `POST /v1/ontology/evolve?dry_run=true` reports conflicts with stored records before anything changes. ```text propose fact ──▶ SHACL check ──▶ conforms? ──yes──▶ commit │ │ └── report ◀── no┘ │ agent reads the result (record, rule, message) and corrects the fact ──▶ retry ``` Standard RDF tools read the same graph, so an agent's memory stays in an open format. Each fact keeps its evidence and its commit history. ## See how a query ran URL: https://docs.littlebigbrain.com/guides/query-profiles/ Source: docs-site/src/content/docs/guides/query-profiles.mdx Description: Ask for a query profile to see the time, the reads and the join order behind one SPARQL answer. Read the statistics the planner uses for a graph. A slow query has a cause. It reads too many blocks, or the planner joins its patterns in a poor order, or it waits for the snapshot. A query profile shows which one. The examples use the `support` graph from [Query with SPARQL & validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). ### Profile a query Set `profile: true` on `POST /v1/query/sparql-text` or `POST /v1/query/sparql`. The answer gains a `profile` object. It describes this one request. ### TypeScript ```ts const query = ` PREFIX dclass: PREFIX dr: PREFIX rdfs: SELECT ?ticket ?release WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dr:reported_as ?b . ?b dr:fixed_in ?r . ?r rdfs:label ?release . }`; const { rows, profile } = await lbb.query.sparql({ query }, { profile: true }); console.log(rows); console.log(profile?.total_ms, profile?.stages); for (const order of profile?.join_orders ?? []) { for (const step of order.steps) console.log(step.estimate, step.pattern); } ``` ### Python ```python query = """ PREFIX dclass: PREFIX dr: PREFIX rdfs: SELECT ?ticket ?release WHERE { ?t a dclass:ticket ; rdfs:label ?ticket ; dr:reported_as ?b . ?b dr:fixed_in ?r . ?r rdfs:label ?release . }""" results = lbb.sparql(query, profile=True) print(results.rows()) print(results.profile["total_ms"], results.profile["stages"]) for order in results.profile["join_orders"]: for step in order["steps"]: print(step["estimate"], step["pattern"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"query": "SELECT ?r WHERE { ?t ?b . ?b ?r }", "profile": true}' \ | jq '.profile | {total_ms, stages, reads, join_orders}' ``` A profiled request does not use the result cache. Its profile always describes an execution, and `result_cache` reads `bypassed`. Without `profile`, the answer is the same as before, byte for byte. ### Read a profile Times are milliseconds. | Field | What it tells you | | --- | --- | | `total_ms` | The whole request inside the server, snapshot included. | | `execution_ms` | The query engine alone. | | `stages` | Time per stage, in order: `resolve_snapshot`, `parse_rewrite`, `execute`, `serialize`. | | `rows`, `rows_total` | Rows in this answer, and rows before `offset` and `limit`. | | `reads` | Blocks and bytes the plan read, and the hits, misses and bytes of the request's block cache. A miss reads from the node's caches or from storage. | | `join_orders` | The order the planner chose for each group of patterns, with the estimate it compared for each pattern. | | `operators` | Rows in and out per operator, such as `filter` and `distinct`. | | `plans` | The specialized plans that ran, such as answers from statistics. | | `trigram` | For a text `FILTER`: `used`, or the reason the text index was not used. | A large `resolve_snapshot` time means the server waited for the graph's snapshot, before any query work started. A large `execute` time with many `reads.blocks` means the plan read a lot of data. In `join_orders`, the pattern with the smallest estimate runs first, and each later estimate counts the variables already bound. The estimates come from the planner statistics below. Compare them with what you know about the data. The join-order record keeps at most 64 orders of 64 patterns each. Literals are cut at 64 characters, and each pattern at 512. ### Bound previews before adding details For a large set of linked entities, select a small membership page in a subquery before joining labels and types. An outer `LIMIT` after grouping or sorting limits the answer size, but can still require reading every member. The engine can stop early for a limited, explicitly bounded set joined to one triple pattern with a known predicate and endpoint. Ordering, grouping, `DISTINCT` and `OPTIONAL` keep their normal SPARQL semantics. The console's linked-group view uses this approach. It loads six members, then 25 more per click, with continuations pinned to the first page's snapshot. Its relationship preview samples a bounded number of members and links, shows the sample size, and marks truncated counts as lower bounds. A failed preview can be retried while already loaded members remain visible. Member pages are sorted within each page; they are not a global alphabetical list. ### Read the planner statistics `GET /v1/graph/planner-stats` returns the statistics the planner uses for the graph's published data. It reads stored metadata and does not scan the graph. ### TypeScript ```ts const stats = await lbb.graph("support").plannerStats({ limit: 50 }); console.log(stats.triple_count, stats.served_at_seq); for (const p of stats.predicates) { console.log(p.iri, p.triples, p.distinct_subjects, p.distinct_objects); } ``` ### Python ```python stats = lbb.planner_stats(limit=50) print(stats["triple_count"], stats["served_at_seq"]) for p in stats["predicates"]: print(p["iri"], p["triples"], p["distinct_subjects"], p["distinct_objects"]) ``` ### cURL ```sh curl --fail-with-body -sS "$LBB_URL/v1/graph/planner-stats?graph=support&limit=50" \ -H "Authorization: Bearer $LBB_API_KEY" | jq '{triple_count, predicates}' ``` `predicates` lists each link and field with its triple count, largest first. Pass `next_cursor` as `cursor` to read the next page. `limit` is 1 to 500, 200 by default. A cursor belongs to one published version of the data. After a new version is published, it returns 400 `planner-stats cursor expired`. Start again without a cursor. The answer also reports the count histograms, the pair-count statistics, the text index for labels, and the value-order index. A graph with no published data answers `served_at_seq: null` and empty lists. ### Time and random functions `NOW()` returns the time of the request, one value for the whole query. `RAND()`, `UUID()` and `STRUUID()` draw new values on each request. The server never caches an answer to a query that uses them. ## Replay answers and history URL: https://docs.littlebigbrain.com/guides/time-travel-audit/ Source: docs-site/src/content/docs/guides/time-travel-audit.mdx Description: Store the commit behind an agent's answer, replay its query with as_of_commit_seq, and see when an answer changed. Covers pinned reads, validation state, valid time and retention. The [quickstart](https://docs.littlebigbrain.com/start/quickstart/) runs its path query at the commit the search read. It passes `as_of_commit_seq: found.served_at_seq`, so search and path read one version of the data. This page shows how to store that commit with an answer and replay the query later. It also shows how to find the commit where an answer changed. The last sections cover pinned reads, valid time and how long history is kept. Commits are explained in [core concepts](https://docs.littlebigbrain.com/start/concepts/#commits). ### How replay works - Each write is a commit with a number, `commit_seq`. A later write never changes an earlier commit. - Each read reports the commit it read. SPARQL text returns `snapshot.served_at_seq`, and search returns `served_at_seq`. - A query with `as_of_commit_seq` reads the graph as it was at that commit. It returns the same rows every time while the commit is retained. - A pinned read is exact. The platform never answers from a nearby commit instead. Replay reproduces the evidence behind an answer. The generated text needs more. To reproduce it, also store the prompt, the model version and its settings. ### 1. Store the answer with its commit The steps on this page continue the quickstart on the same `support` graph. Run the quickstart first. Store these with each answer: | Item | Why | | --- | --- | | The graph name | A commit number belongs to one graph. | | The question and the search hits | Search takes no commit pin, so store its hits. | | The query text and its options | A replay runs the same query. | | The rows | You can compare a replay with what the agent saw. | | The commit, `served_at_seq` | A replay reads the graph at this commit. | Add this snippet to the end of your quickstart file and run the file once. It writes `answer.json` on the first run only, so later runs keep the first answer. ### TypeScript ```ts import { existsSync, writeFileSync } from "node:fs"; if (!existsSync("answer.json")) { const answer = { graph: "support", question: "users cannot sign in after the upgrade", hits: found.hits.map((hit) => ({ iri: hit.iri, label: hit.label, score: hit.score })), query, rows, commit: found.served_at_seq, }; writeFileSync("answer.json", JSON.stringify(answer, null, 2)); } ``` ### Python ```python if not os.path.exists("answer.json"): answer = { "graph": "support", "question": "users cannot sign in after the upgrade", "hits": [{"iri": h["iri"], "label": h["label"], "score": h["score"]} for h in found["hits"]], "query": query, "rows": rows, "commit": found["served_at_seq"], } with open("answer.json", "w") as f: json.dump(answer, f, indent=2) ``` ### cURL Run this in the directory that holds `found.json` from the quickstart. It stores the query as `path.rq`, then the answer without its rows. ```sh cat > path.rq < PREFIX rdfs: SELECT ?bug ?release WHERE { <$(jq -r '.hits[0].iri' found.json)> dr:reported_as ?b . ?b rdfs:label ?bug ; dr:fixed_in ?r . ?r rdfs:label ?release . } EOF [ -f answer.json ] || jq --rawfile query path.rq '{ graph: "support", question: "users cannot sign in after the upgrade", hits: [.hits[] | {iri: .iri, label: .label, score: .score}], query: $query, commit: .served_at_seq }' found.json > answer.json ``` On a new graph, `commit` is `1`. ### 2. Correct the data Suppose the release notes were wrong. The fix for `AUTH-112` shipped in 3.2.1. Two writes correct the graph. The first retracts the old `FIXED_IN` link. The second adds the new one. Create a new file, `correct.mts` or `correct.py`, and run it once: ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); const auth = { type: "Bug", key: "AUTH-112", name: "session token expiry" }; const r32 = { type: "Release", key: "3.2", name: "Release 3.2" }; const r321 = { type: "Release", key: "3.2.1", name: "Release 3.2.1" }; await lbb.graph("support").retract( { edges: [{ source: auth, relation: "FIXED_IN", target: r32 }] }, { idempotencyKey: "fix-moved-1" }, ); const corrected = await lbb.graph("support").facts.create( { triplets: [{ source: auth, relation: "FIXED_IN", target: r321, evidence: "Release notes 3.2.1" }] }, { idempotencyKey: "fix-moved-2" }, ); await lbb.waitForPublished(corrected.commit_seq); console.log("corrected at commit", corrected.commit_seq); ``` ### Python ```python import os from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") graph = lbb.graph("support") auth = {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"} r32 = {"type": "Release", "key": "3.2", "name": "Release 3.2"} r321 = {"type": "Release", "key": "3.2.1", "name": "Release 3.2.1"} graph.retract( {"edges": [{"source": auth, "relation": "FIXED_IN", "target": r32}]}, idempotency_key="fix-moved-1", ) corrected = graph.facts.create( {"triplets": [{"source": auth, "relation": "FIXED_IN", "target": r321, "evidence": "Release notes 3.2.1"}]}, idempotency_key="fix-moved-2", ) graph.wait_for_published(corrected["commit_seq"]) print("corrected at commit", corrected["commit_seq"]) ``` ### cURL ```sh AUTH='{"type": "Bug", "key": "AUTH-112", "name": "session token expiry"}' curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/retract?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: fix-moved-1" \ -d "{\"edges\": [{\"source\": $AUTH, \"relation\": \"FIXED_IN\", \"target\": {\"type\": \"Release\", \"key\": \"3.2\", \"name\": \"Release 3.2\"}}]}" curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: fix-moved-2" \ -d "{\"triplets\": [{\"source\": $AUTH, \"relation\": \"FIXED_IN\", \"target\": {\"type\": \"Release\", \"key\": \"3.2.1\", \"name\": \"Release 3.2.1\"}, \"evidence\": \"Release notes 3.2.1\"}]}" until curl -sS "$LBB_URL/v1/graph/publication-status?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done ``` After the quickstart, the SDK examples print `corrected at commit 3`. The retraction is commit 2. A retraction is a new commit, and commit 1 still holds the old link. ### 3. Replay the answer Replay reads `answer.json` and runs the stored query twice. The first run is pinned to the stored commit. The second reads the latest published commit. Create `replay.mts` or `replay.py`: ### TypeScript ```ts import { readFileSync } from "node:fs"; import { LbbClient, parseSparqlResults } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); const answer = JSON.parse(readFileSync("answer.json", "utf8")); const then = await lbb.sparqlText({ query: answer.query, as_of_commit_seq: answer.commit }); const now = await lbb.sparqlText({ query: answer.query }); console.log("commit", then.snapshot?.served_at_seq, parseSparqlResults(then).rows); console.log("commit", now.snapshot?.served_at_seq, parseSparqlResults(now).rows); ``` ### Python ```python import json import os from lbb import LbbClient, SparqlResults lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") with open("answer.json") as f: answer = json.load(f) def run(body): resp = lbb.raw_request("POST", "/v1/query/sparql-text", params={"graph": answer["graph"]}, body=body) return resp.data["snapshot"]["served_at_seq"], SparqlResults.from_envelope(resp.data).rows() print("commit", *run({"query": answer["query"], "as_of_commit_seq": answer["commit"]})) print("commit", *run({"query": answer["query"]})) ``` ### cURL ```sh for body in "$(jq '{query, as_of_commit_seq: .commit}' answer.json)" "$(jq '{query}' answer.json)"; do curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d "$body" \ | jq -c '{commit: .snapshot.served_at_seq, rows: [.results | fromjson | .results.bindings[] | {bug: .bug.value, release: .release.value}]}' done ``` The TypeScript example prints this: ```text commit 1 [ { bug: 'session token expiry', release: 'Release 3.2' } ] commit 3 [ { bug: 'session token expiry', release: 'Release 3.2.1' } ] ``` The replay still returns Release 3.2, the answer the agent gave. The latest data says Release 3.2.1. Both results name their commit. The Python SDK's `sparql()` method takes no `as_of_commit_seq`. Use `raw_request` for a pinned read, as above. ### 4. Find the commit where the answer changed Run the stored query at each commit from the stored one to the latest. Add this to the end of `replay.mts` or `replay.py`: ### TypeScript ```ts const { published_seq } = await lbb.publicationStatus(); for (let seq = answer.commit; seq <= published_seq; seq++) { const res = await lbb.sparqlText({ query: answer.query, as_of_commit_seq: seq }); const releases = parseSparqlResults(res).rows.map((row) => row.release); console.log(seq, releases.join(", ") || "no rows"); } ``` ### Python ```python published = lbb.publication_status()["published_seq"] for seq in range(answer["commit"], published + 1): _, rows = run({"query": answer["query"], "as_of_commit_seq": seq}) print(seq, ", ".join(row["release"] for row in rows) or "no rows") ``` ### cURL ```sh LATEST=$(curl -sS "$LBB_URL/v1/graph/publication-status?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" | jq '.published_seq') for seq in $(seq "$(jq '.commit' answer.json)" "$LATEST"); do printf '%s ' "$seq" curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d "$(jq --argjson seq "$seq" '{query, as_of_commit_seq: $seq}' answer.json)" \ | jq -r '[.results | fromjson | .results.bindings[].release.value] | join(", ")' done ``` Each example prints one line per commit: ```text 1 Release 3.2 2 no rows 3 Release 3.2.1 ``` Commit 2 removed the old link, and commit 3 added the new one. cURL prints nothing after the `2`. This runs one query per commit. For a long history, compare the stored commit with the latest first. Then halve the range until you find the commit. ### Where a commit pin works | Surface | How to pin | Notes | | --- | --- | --- | | `POST /v1/query/sparql-text` | `as_of_commit_seq` in the JSON body | TypeScript: `sparqlText({ query, as_of_commit_seq })`. Python: `raw_request`. | | `GET` or `POST /sparql` | `?as_of_commit_seq=` | For standard SPARQL tools. | | `POST /v1/query/sparql` | `as_of_commit_seq` in the JSON body | The structured JSON query. | | `GET /v1/graph/entity` | `?as_of_commit_seq=` | One JSON record with its links. TypeScript: `entityDetail({ type, key, asOfCommitSeq })`. | | `POST /v1/search` | No pin | Store the hits with the answer. | A pin works the same on a graph loaded from RDF. `GET /v1/graph/entity` reads JSON records only. On an RDF-native graph, query the subject IRI with SPARQL instead. A pinned response describes the commit in `snapshot`: | Field | Meaning | | --- | --- | | `as_of_commit_seq` | The pin you sent. | | `served_at_seq` | The commit the read used. It equals the pin. | | `commit_seq` | The newest commit of the graph. | | `stale`, `stale_reason` | `true` and `eventual_consistency` when the pin is older than the newest commit. This is expected for a replay. | These requests fail: | Request | Response | | --- | --- | | A pin above the newest commit | `400 invalid_request`, `as-of commit 9 is beyond graph head 3` | | `min_indexed_seq` above the pin | `400 invalid_request` | | A commit that is not retained | `400 historical_generation_unavailable`. Retrying does not help. | | `/sparql?as_of_valid_time=` | `400 historical_generation_unavailable`. See [Valid time](#valid-time). | ### Record the validation state If the graph has SHACL shapes, store the validation state with the answer. `GET /v1/ontology/conformance` returns the report of the latest validated commit: ```sh curl --fail-with-body -sS "$LBB_URL/v1/ontology/conformance?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" ``` Store `conforms`, `result_count`, `validated_at_seq` and `ontology_version`. Store `shapes_version` too when the graph has published shapes. The report covers the commit in `validated_at_seq`. A request never validates a past commit. With `consistency=strong`, the route returns a retryable `429 strong_read_pending` until the report covers the newest commit. ### How long history is kept A pinned read works while the commit is retained. A commit that is no longer retained fails with `historical_generation_unavailable`. The read never returns a nearby commit instead. Deleting the graph deletes its history. Keep your own copy of evidence that must outlive the graph. That is the question, the query, the rows and the commit. The `answer.json` from step 1 is such a copy. ### Valid time A commit pin answers one question: what did the graph record at commit N? A second question is when a fact was true in the world. That is valid time. A link can carry `valid_time` with a `start` and an `end`: ```json { "source": {"type": "Bug", "key": "AUTH-112", "name": "session token expiry"}, "relation": "FIXED_IN", "target": {"type": "Release", "key": "3.2", "name": "Release 3.2"}, "valid_time": {"start": "2026-09-02T00:00:00Z"} } ``` Hosted graphs store `valid_time` but do not query by it. SPARQL and the record read take commit pins only. To ask what was true on a date, store the dates as `date_time` fields. Then filter on them in SPARQL. [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/) shows a `date_time` field. ### Related - [Quickstart](https://docs.littlebigbrain.com/start/quickstart/): the search and path query this page replays. - [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/): pins work the same on RDF data. - [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): SPARQL, SHACL shapes and the conformance report. - [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/): combine `entailment` with a commit pin. - [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/): every route and parameter. ## Reasoning & inference URL: https://docs.littlebigbrain.com/guides/reasoning/ Source: docs-site/src/content/docs/guides/reasoning.mdx Description: Let a query also return the facts that follow from your schema, such as "an active study site is a study site". You choose the rules per query with the entailment option. A query can return the facts you wrote and the facts that follow from your schema. Say the schema states that an active study site is a study site. A query for study sites can then include the active ones. You choose this per query with the `entailment` option. Stored data does not change. This page uses the `recall` graph from [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/). The RDF words are explained in [core concepts](https://docs.littlebigbrain.com/start/concepts/#rdf-under-the-hood). ### What `entailment` does RDFS and OWL are W3C standards for statements about types and links. `rdfs:subClassOf`, for example, states that every record of one type also has another type. The `entailment` option selects which of these statements a query applies: | `entailment` | The query also returns | Statements it reads | | --- | --- | --- | | `none` (default) | Nothing extra: the facts as written. | None. | | `subclass` | Records of every subtype. A query for study sites matches active study sites. | `rdfs:subClassOf` | | `rdfs` | Everything `subclass` returns. Values written under a more specific link or field. Types that follow from a link's declared source or target type. | `rdfs:subClassOf`, `rdfs:subPropertyOf`, `rdfs:domain`, `rdfs:range` | | `owl` | Everything `rdfs` returns. Equal types, links and records, reverse links, symmetric and transitive links, link chains, and class rules. | The [OWL statements](#supported-owl-statements) below. | In RDF, a type is a **class** and a link or field is a **property**. A "subproperty" is a more specific link or field. Reasoning follows these rules: - It runs when the query runs. No fact is copied, and no index is rebuilt. - It reads the schema statements of the commit the query reads. The same query at the same commit returns the same rows. - A new schema statement counts from the commit that writes it. A strong read applies it at once. An eventual read applies it once that commit is published. - With `as_of_commit_seq`, a query reasons over the data and the schema of that earlier commit. - A `super_types` link is part of the JSON schema, which changes without a commit. A strong read applies a new link at once. An eventual read applies it after the graph republishes. A read of an earlier commit can apply links added later. #### Where you set it | Surface | How to pass it | | --- | --- | | `POST /v1/query/sparql-text` | Body field `"entailment": "subclass"` | | `GET` or `POST /sparql` | URL parameter `entailment=subclass` | | TypeScript SDK | `lbb.sparqlText({ query, entailment })` or `lbb.sparqlRows({ query, entailment })` | | Python SDK | `lbb.sparql(query, entailment="subclass")` | | MCP | `lbb_query` with `mode: "sparql"` and `entailment` | | Console | **Knowledge graph → Ontology → Inferred facts**, see [In the console](#in-the-console) | The structured JSON query, `POST /v1/query/sparql`, has no `entailment` field. It matches the facts as written. An unknown `entailment` value returns HTTP 400 `invalid_request`. ### Add a type hierarchy to JSON records A JSON schema can list `super_types` for a type. The graph stores each super type as an `rdfs:subClassOf` statement. A query can then reason over it. The `recall` graph declares `ActiveStudySite` with `super_types: ["StudySite"]`, so the graph holds this statement: ```text . ``` Each type you declare in JSON has a global name, an IRI. It is the type name in lowercase after `https://littlebigbrain.com/class/`. To give an existing type a parent, call `evolve` with `add_super_types`. Repeating a link does nothing. A strong read applies a new link at once. An eventual read applies it once the graph republishes, usually within seconds. The code below adds the link and waits for publication. Then it runs one query twice, without and with `entailment`. Add each snippet on this page to the end of one file, as in the [quickstart](https://docs.littlebigbrain.com/start/quickstart/). ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "recall", }); await lbb.graph("recall").ontology.evolve({ ops: [{ op: "add_super_types", entity_type: "ActiveStudySite", super_types: ["StudySite"] }], }); const { head_seq } = await lbb.publicationStatus(); await lbb.waitForPublished(head_seq); const sites = ` PREFIX dclass: PREFIX rdfs: SELECT ?site WHERE { ?s a dclass:studysite ; rdfs:label ?site } ORDER BY ?site`; const asWritten = await lbb.sparqlRows({ query: sites }); const withSubtypes = await lbb.sparqlRows({ query: sites, entailment: "subclass" }); console.log(asWritten.rows, withSubtypes.rows); ``` ### Python ```python import os from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="recall") lbb.ontology.evolve({ "ops": [{"op": "add_super_types", "entity_type": "ActiveStudySite", "super_types": ["StudySite"]}], }) lbb.wait_for_published(lbb.publication_status()["head_seq"]) sites = """ PREFIX dclass: PREFIX rdfs: SELECT ?site WHERE { ?s a dclass:studysite ; rdfs:label ?site } ORDER BY ?site""" print(lbb.sparql(sites).rows()) print(lbb.sparql(sites, entailment="subclass").rows()) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"ops": [{"op": "add_super_types", "entity_type": "ActiveStudySite", "super_types": ["StudySite"]}]}' until curl -sS "$LBB_URL/v1/graph/publication-status?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done for mode in none subclass; do curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d "{\"entailment\": \"$mode\", \"query\": \"PREFIX dclass: PREFIX rdfs: SELECT ?site WHERE { ?s a dclass:studysite ; rdfs:label ?site } ORDER BY ?site\"}" \ | jq -r '.results | fromjson | .results.bindings[].site.value' done ``` The two queries return different rows from the same records: | `entailment` | Rows | | --- | --- | | `none` | Oslo Cardiology Study Site | | `subclass` | Berlin Cardiology Study Site, Oslo Cardiology Study Site | Berlin is an `ActiveStudySite`. The hierarchy makes it a study site as well. No record changed. An agent adds the same link with the MCP tool `evolve_ontology`. See [MCP for agents](https://docs.littlebigbrain.com/sdks/mcp/#schema-calls). For a class outside the JSON schema, write the same statement as RDF. Load it with [RDF import](https://docs.littlebigbrain.com/guides/load-rdf/) or SPARQL Update. ### Answer a question with OWL OWL statements describe links as well as types. Three statements let one plain pattern answer the recall question. Which released batches are exposed to the recall? ```turtle @prefix owl: . @prefix dr: . @prefix ex: . dr:produced_from a owl:TransitiveProperty . ex:recalledBy owl:inverseOf dr:recalls . ex:exposedTo owl:propertyChainAxiom ( dr:produced_from ex:recalledBy ) . ``` - `owl:TransitiveProperty` makes a chain of `PRODUCED_FROM` links count as one link. A batch is then produced from every lot upstream of it. - `owl:inverseOf` declares `ex:recalledBy` as the reverse of `RECALLS`. - `owl:propertyChainAxiom` defines `ex:exposedTo` as `PRODUCED_FROM`, then `recalledBy`. A batch is exposed to each notice that recalls a lot it came from. The JSON schema already names `RECALLED_BY` as the inverse of `RECALLS`. That name works as a query alias, `dr:recalled_by`. OWL statements do not see aliases, so the document declares the reverse link with `owl:inverseOf`. The code below loads the document with the RDF import. The import adds `Resource`, `RdfLiteral` and `RDF_TRIPLE` to the graph's schema. After that, running the guide's `define` call again fails with `400 ontology_identity_breaking_change`. Use `evolve` for later schema changes. ### TypeScript ```ts const axioms = ` @prefix owl: . @prefix dr: . @prefix ex: . dr:produced_from a owl:TransitiveProperty . ex:recalledBy owl:inverseOf dr:recalls . ex:exposedTo owl:propertyChainAxiom ( dr:produced_from ex:recalledBy ) . `; const loaded = await lbb.graph("recall").facts.importRdf(axioms, { format: "turtle", idempotencyKey: "recall-axioms-1", }); await lbb.waitForPublished(loaded.committed_commit_seq!); const exposed = await lbb.sparqlRows({ query: ` PREFIX dclass: PREFIX rdfs: PREFIX ex: SELECT ?batch WHERE { ?b a dclass:releasedbatch ; ex:exposedTo ?notice ; rdfs:label ?batch . ?notice rdfs:label "FDA valsartan recall" . }`, entailment: "owl", }); console.log(exposed.rows); ``` ### Python ```python axioms = """ @prefix owl: . @prefix dr: . @prefix ex: . dr:produced_from a owl:TransitiveProperty . ex:recalledBy owl:inverseOf dr:recalls . ex:exposedTo owl:propertyChainAxiom ( dr:produced_from ex:recalledBy ) . """ loaded = lbb.graph("recall").facts.import_rdf( axioms, format="turtle", idempotency_key="recall-axioms-1" ) lbb.wait_for_published(loaded["committed_commit_seq"]) exposed = lbb.sparql(""" PREFIX dclass: PREFIX rdfs: PREFIX ex: SELECT ?batch WHERE { ?b a dclass:releasedbatch ; ex:exposedTo ?notice ; rdfs:label ?batch . ?notice rdfs:label "FDA valsartan recall" . }""", entailment="owl") print(exposed.rows()) ``` ### cURL Save the three statements as `axioms.ttl`, then: ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/import/rdf?graph=recall&format=turtle" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: text/turtle" \ -H "Idempotency-Key: recall-axioms-1" --data-binary @axioms.ttl until curl -sS "$LBB_URL/v1/graph/publication-status?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=recall" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d '{"entailment": "owl", "query": "PREFIX dclass: PREFIX rdfs: PREFIX ex: SELECT ?batch WHERE { ?b a dclass:releasedbatch ; ex:exposedTo ?notice ; rdfs:label ?batch . ?notice rdfs:label \"FDA valsartan recall\" . }"}' \ | jq -r '.results | fromjson | .results.bindings[].batch.value' ``` The query returns one row, `Cardiovex batch CVX-A17`. The batch reaches the recalled lot through the granulation lot. With `entailment: "none"`, the same query returns no rows, because no `exposedTo` fact is stored. ### Supported OWL statements `entailment: "owl"` applies everything `rdfs` applies. On top of that, it reads these OWL statements from the schema: | Group | Statements | What a query returns | | --- | --- | --- | | Equivalence | `owl:equivalentClass`, `owl:equivalentProperty` | Equal types share members, and equal links share values, in both directions. | | Link rules | `owl:inverseOf` | A fact answers from either direction. This includes facts on a subproperty of either side: `x directlyOwns y` answers `y ownedBy x` when `directlyOwns` is a subproperty of `owns`. | | | `owl:SymmetricProperty` | The link matches in both orders. | | | `owl:TransitiveProperty` | Chains of the link and its subproperties count as one link. | | | `owl:propertyChainAxiom` | A chain of links derives the head link. A chain may use another chain's head, up to 8 levels and 256 expanded path terms. Chains must not be recursive. | | Class rules | `owl:unionOf` | Each member type is a subtype of the union. | | | `owl:intersectionOf` | The intersection is a subtype of each part, and a record in every part is a member. | | | `owl:oneOf` | The listed records are members of the type. | | Restrictions | `owl:hasValue` | A record with the value is a member, and a member has the value. | | | `owl:someValuesFrom` | A record linked to a record of the filler type is a member. | | | `owl:allValuesFrom` | A record reached from a member through the restricted link gets the filler type. | | Identity | `owl:sameAs` | Records in one `sameAs` group are one record. A fixed IRI matches through any member, and a variable binds every member. | The restriction rules combine with inverse, symmetric, transitive, subproperty, chain and `hasValue` facts. A restriction names its link with `owl:onProperty`. ### Limits Some schemas are outside what the regimes can answer exactly. The query then fails with HTTP 400 `invalid_request`. It never returns a partial answer. The message starts with `OWL entailment is unavailable` or `RDFS entailment is unavailable`. | Limit | Value | Regimes refused | | --- | --- | --- | | Schema closure (type pairs, link pairs and domain and range pairs) | 1,000,000 pairs | `subclass`, `rdfs`, `owl` | | RDF list cells behind `unionOf`, `intersectionOf`, `oneOf` and chains | 100,000 | `owl` | | Property chain composition | 8 levels, 256 expanded terms, no recursion | `owl` | | `owl:sameAs` statements | 100,000 pairs | `owl` | | One `owl:sameAs` group | 256 records | `owl` | A query without `entailment` keeps working on the same graph. These are not inferred: - Existential superclasses and facts derived from cardinality. - Documents named by `owl:imports`. Import each document instead. - Axiomatic RDF triples, container membership, and rules about `rdfs:Resource` or `rdfs:Datatype`. - Schema statements whose subject or object is a blank node. OWL restriction classes are the exception. Cardinality declarations are checked as validation rules. They appear in the [published conformance report](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). Entailment does not apply inside a property path, such as `?b ex:exposedTo/rdfs:label ?x` or `dr:produced_from+`. Write each step as its own pattern. A path over the stored statements walks a type hierarchy without entailment: `?s a/rdfs:subClassOf* dclass:studysite`. ### Performance - The schema rules are computed once per published commit and schema version. The first query that uses `entailment` computes them. Later queries reuse them. - A query pattern that no schema statement mentions runs the same plan with and without entailment. - Transitive links and chains are read as index traversals from the bound end of the pattern. The normal query time limit applies. - An aggregate over a large type with subtypes reads every member. The answer is exact, and the cost grows with the member count. ### Rules at query time A query cannot run custom inference rules. A request with `reason: true` returns HTTP 400 `invalid_request`. The message names the `entailment` regimes to use instead. `reason=True` in the Python SDK's `sparql()` returns the same error. ### In the console **Knowledge graph → Ontology** always lists a class with its subclasses' records. The **Inferred facts** switch adds what the ontology implies, by OWL reasoning, to instance lists and records. Inferred results are marked in amber. Query errors appear inline, so you can retry or turn the switch off. **OWL axioms** shows the schema statements as diagrams, grouped by type and link. The legend explains three marks: - A solid arrow is a link in your data. - An amber dashed arrow is what reasoning adds. - A **Validation** badge marks a rule that is checked and never inferred. For example, the path `memberOf` then `owns` is drawn above a dashed `worksOn` arrow. Both connect the same two records. A link with a declared source and target type starts with a **Connects** diagram. More behavior of the **OWL axioms** view: - Hover or tap the ⓘ next to a rule title to read it as a sentence. - Search, or filter by **Types** and **Relationships**. - `owl:sameAs` links between individuals appear on the individual's card in **Ontology**. - The header arrow opens the entity. **OWL details** shows the full IRI, predicates and expressions such as `worksOn some Project`. - Lettered nodes stand for any record. They are not query results. - Diagrams show stored schema statements. They do not change with the reasoning mode. - Cards come in pages of 24. The view reads at most 20,000 statements and labels a partial result. - Imported ontology metadata stored outside the RDF graph does not appear here. **Knowledge graph → Ontology** shows one class or entity at a time. An instance opens as a record: its values, and its relationships beside them. **Map** shows it among its links. Click a linked item to move to it. The trail above the view leads back. A class card shows its definition. **Browse instances** pages through every member, with an exact count and a label filter. With **Inferred facts** on, members added by reasoning are marked **inferred**. ### Why reasoning helps an agent - **The same answer each run.** Transitive links, type hierarchies and classifications follow from the schema. An LLM that derives them itself can answer differently each call, and it spends tokens. - **Broader answers without more writes.** A query for a general type returns the records of its subtypes. The agent does not list every subtype. - **One record under many names.** With `owl:sameAs`, an agent writes facts under the IRI it has. It reads the facts written under every equal IRI. ### Related - [Build with JSON records](https://docs.littlebigbrain.com/guides/without-rdf/): the `recall` graph this page uses. - [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/): import RDF and OWL documents. - [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): the query surfaces that reasoning applies to. - [Replay an answer](https://docs.littlebigbrain.com/guides/time-travel-audit/): pin a query to an earlier commit. - [Core concepts](https://docs.littlebigbrain.com/start/concepts/) ## Load RDF data URL: https://docs.littlebigbrain.com/guides/load-rdf/ Source: docs-site/src/content/docs/guides/load-rdf.mdx Description: Load Turtle, N-Triples, N-Quads or TriG from another triple store or a public dataset, then query it with SPARQL. Covers formats, parameters, limits, large loads and validation. This page is for data that is RDF already. Examples are Turtle or N-Triples files, a dump from another triple store, or a public dataset. The import stores each statement as written. Your SPARQL queries use your own predicate IRIs. If your data is JSON, rows or documents, start with [Load your data](https://docs.littlebigbrain.com/start/load-data/). The RDF words on this page are explained in [core concepts](https://docs.littlebigbrain.com/start/concepts/#rdf-under-the-hood). ### The first commit selects the write model A graph has one of two write models. Its first commit selects the model, and the model never changes. Schema calls such as `define` and `evolve` do not select it. | First commit | Model (`truth`) | Later writes | | --- | --- | --- | | RDF import or SPARQL Update | `rdf_native` | RDF import and SPARQL Update `INSERT DATA` only | | JSON records (commit or NDJSON import) | `property_graph` | JSON records and RDF import | An RDF-native graph refuses the JSON record routes. Each returns `400` with the error code `rdf_native_graph`: | Refused on an RDF-native graph | Use instead | | --- | --- | | `POST /v1/graph/commit` without `replace_owned`, and `?dry_run=true` | `POST /v1/graph/import/rdf`, or SPARQL Update `INSERT DATA` | | `POST /v1/graph/import` | `POST /v1/graph/import/rdf` | | `POST /v1/graph/reload` | Delete the graph and load it again | | `POST /v1/graph/retract` | Delete the graph and load it again | `POST /v1/graph/import-jobs` accepts the upload with `202`. The job then fails at its first commit with `import_infrastructure_failure`. A retry fails the same way. SPARQL queries, `POST /v1/ontology/evolve` and schema publishing work on both models. SPARQL Update accepts `INSERT DATA` only. A writer that replaces its own facts can do so: files, the document workflows, an accepted review item and a commit with `replace_owned` remove what they wrote before and no longer write. To remove other statements, delete the graph with `POST /v1/graph/delete?graph=research&confirm=research` and load the corrected data. Synchronous RDF import can recreate the deleted graph. If the new load uses durable NDJSON `POST /v1/graph/import-jobs`, explicitly recreate it with `POST /v1/graph/create?graph=research`, install the JSON-record schema, and use a new `Idempotency-Key`. Durable submission refuses a retained deletion tombstone with `409 graph_epoch_changed`; old jobs cannot adopt the recreated graph. See [Bulk NDJSON ingest](https://docs.littlebigbrain.com/sdks/http-api/#bulk-ndjson-ingest). To mix JSON records and RDF in one graph, write the JSON records first. See [Add RDF to a JSON-records graph](#add-rdf-to-a-json-records-graph). `GET /v1/status` reports the model as `graph_status.truth`. Before the first commit, it reads `property_graph`. ```sh curl --fail-with-body -sS "$LBB_URL/v1/status?graph=research" \ -H "Authorization: Bearer $LBB_API_KEY" | jq '.graph_status.truth' ``` ### Load a file The examples load this Turtle file into a new graph named `research`. Save it as `dataset.ttl`: ```turtle @prefix foaf: . @prefix dcterms: . a foaf:Person ; foaf:name "Ada Lovelace" . a foaf:Person ; foaf:name "Charles Babbage" . dcterms:title "Notes on the Analytical Engine"@en ; dcterms:creator ; dcterms:references . dcterms:title "The Analytical Engine"@en ; dcterms:creator . ``` The IRIs `` and `` are relative. `base_iri` resolves them to `https://example.org/data/ada` and so on. The first import creates the graph, and the graph is then RDF-native. After the import, wait until the commit is published. Queries read published data. This usually takes a second or two. ### TypeScript ```ts import { readFileSync } from "node:fs"; import { LbbClient } from "@littlebigbrain/client"; const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "research", }); const loaded = await lbb.graph("research").facts.importRdf(readFileSync("dataset.ttl", "utf8"), { format: "turtle", baseIri: "https://example.org/data/", idempotencyKey: "research-load-1", }); await lbb.waitForPublished(loaded.committed_commit_seq!); console.log(loaded.triples_read, "statements in commit", loaded.committed_commit_seq); ``` ### Python ```python import os from lbb import LbbClient lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="research") graph = lbb.graph("research") with open("dataset.ttl") as f: loaded = graph.facts.import_rdf( f.read(), format="turtle", base_iri="https://example.org/data/", idempotency_key="research-load-1", ) graph.wait_for_published(loaded["committed_commit_seq"]) print(loaded["triples_read"], "statements in commit", loaded["committed_commit_seq"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST \ "$LBB_URL/v1/graph/import/rdf?graph=research&base_iri=https://example.org/data/" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: text/turtle" \ -H "Idempotency-Key: research-load-1" \ --data-binary @dataset.ttl until curl -sS "$LBB_URL/v1/graph/publication-status?graph=research" \ -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done ``` On a new graph, the SDK examples print `9 statements in commit 1`. The response counts what the import read and stored: | Field | Meaning | | --- | --- | | `graph_created` | `true` when this import created the graph. | | `lines_read` | Non-empty lines in the body. | | `triples_read` | Statements parsed. | | `resource_triples`, `literal_triples` | Statements whose object is an IRI or blank node, and statements whose object is a literal. | | `duplicate_resource_triples`, `duplicate_literal_triples` | Statements repeated inside this request. Each is stored once. | | `imported_triplets` | Statements sent to storage. | | `batches` | Internal commits this request made. | | `committed_commit_seq` | The last commit this request made. Wait for this one. | | `predicate_count`, `predicates` | Each predicate IRI in the request, with its statement counts. | | `error_count`, `errors` | Lines that failed to parse. `errors` lists the first 100 messages. | | `published_generation` | The publication this request queued. Absent with `build=false`. | ### Formats and parameters `POST /v1/graph/import/rdf` takes the RDF document as the request body. | Format | `Content-Type` | `?format=` | | --- | --- | --- | | N-Triples | `application/n-triples` | `ntriples`, `nt`, `n-triples` | | Turtle | `text/turtle` | `turtle`, `ttl` | | N-Quads | `application/n-quads` | `nquads`, `nq`, `n-quads` | | TriG | `application/trig` | `trig` | `?format=` overrides the content type. Any other content type is read as N-Triples. JSON-LD and RDF/XML are not supported, and `?format=jsonld` returns `400`. Convert such files to N-Triples or Turtle first. Named graphs are not supported. Each graph serves the default RDF graph only. N-Quads and TriG can name a graph per statement. Such a statement is reported in `errors` and not stored. With `strict=true`, it fails the request. | Parameter | Default | Effect | | --- | --- | --- | | `graph` | `main` | The graph to write. | | `base_iri` | none | Base IRI for relative IRIs in Turtle and TriG. | | `strict` | `false` | `true` fails the request with `400` at the first parse error. By default, a bad line is skipped and reported in `errors`. | | `batch` | `100000` | Statements per internal commit, from 1 to 1,000,000. | | `build` | `true` | `false` stores the statements without queuing publication. See [Load a large dataset](#load-a-large-dataset). | | `blank_node_scope` | none | Keeps blank nodes from different source documents apart. At most 255 ASCII letters, digits, `-`, `_`, `.`, `:` or `/`. | | `resource_type` | `Resource` | Name of the record type the import registers in the schema. | | `observed_at` | commit time | An RFC 3339 time stored with the imported links on a JSON-records graph. RDF-native graphs ignore it. | | `edge_idempotency` | `skip_unchanged` | Accepts `skip_unchanged` or `append`. It does not change an RDF import. | | `graph_uri` | none | Refused with `400`. Import into the default graph. | The `Idempotency-Key` header is optional on this route. The SDKs send one. A request with more statements than `batch` makes several internal commits. It is not one transaction. With `strict=true`, batches before the error stay committed. ### What the import stores The import stores each statement as written. Queries use your predicate IRIs, such as `foaf:name`, and your subject IRIs. A statement that is already stored does not appear twice in query results. The import does not add one link type to the schema per predicate. A dataset with a million predicates keeps a small schema. The import registers a fixed set instead. It adds the record types `Resource` and `RdfLiteral` and one link, `RDF_TRIPLE`. Each entry in `predicates` has these fields: | Field | Meaning | | --- | --- | | `uri`, `lbb_predicate_iri` | The source predicate IRI. | | `relation`, `lbb_relation_iri` | The fixed link, `RDF_TRIPLE`, and its IRI. | | `resource_triples`, `literal_triples`, `triples` | Statement counts for this predicate. | ### Load a large dataset A request can be at most 64 MiB, headers included. Send a larger dataset as several requests. - **N-Triples and N-Quads** have one statement per line. Split them on line boundaries. - **Turtle and TriG** are documents. Each chunk must be a complete Turtle or TriG document, with its own prefixes. Each request publishes its commits by default. For a load of many chunks, send `build=false` on every chunk except the last. The last chunk then queues one publication for the whole load. A `build=false` stream still starts publication in one case. That is when 8 commits, or 32 MiB of statements, wait for it. ```sh split -l 200000 dataset.nt chunk- set -- chunk-* n=0 for file in "$@"; do n=$((n + 1)) build=false; [ "$n" -eq "$#" ] && build=true curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/import/rdf?graph=research&build=$build" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/n-triples" \ -H "Idempotency-Key: research-bulk-$file" \ --data-binary @"$file" done ``` Pick a line count that keeps each file under 64 MiB. The SDK helpers do the same. They send `build=false` on every document except the last. They add `:1`, `:2` and so on to your idempotency key. ### TypeScript ```ts import { readdirSync } from "node:fs"; const chunks = readdirSync(".").filter((file) => file.startsWith("chunk-")).sort(); const bulk = await lbb.graph("research").facts.importRdfMany( chunks.map((file) => ({ rdf: readFileSync(file, "utf8") })), { format: "ntriples", idempotencyKey: "research-bulk-1" }, ); await lbb.waitForPublished(bulk.finalSequence!, { timeoutMs: 600_000 }); console.log(bulk.imports.length, "chunks, last commit", bulk.finalSequence); ``` ### Python ```python from pathlib import Path chunks = sorted(Path(".").glob("chunk-*")) bulk = graph.facts.import_rdf_many( [chunk.read_text() for chunk in chunks], format="ntriples", idempotency_key="research-bulk-1", ) graph.wait_for_published(bulk["final_sequence"], timeout=600) print(len(bulk["imports"]), "chunks, last commit", bulk["final_sequence"]) ``` These snippets continue the code from [Load a file](#load-a-file). Add them to the end of the same file. #### Retries and idempotency keys Give each chunk its own `Idempotency-Key`. A retry with the same key writes nothing. It returns the first result, with the same `committed_commit_seq`. The key identifies the request, and the body is not compared. A reused key with a changed body also returns the first result and stores nothing new. Use a new key for new data. #### Blank nodes across chunks A blank node label keeps its identity across requests. Splitting one N-Triples file keeps `_:b0` the same node in every chunk. Two different source documents can both use `_:b0` for different nodes. Send each document's chunks with its own `blank_node_scope`, such as `blank_node_scope=doc-2`. The two nodes then stay distinct. ### Wait for publication Every accepted import is stored at once. Queries by default read the latest published commit, so a new load is visible after publication. `GET /v1/graph/publication-status` reports progress. It works before the first publication too. ```sh curl --fail-with-body -sS "$LBB_URL/v1/graph/publication-status?graph=research" \ -H "Authorization: Bearer $LBB_API_KEY" ``` | Field | Meaning | | --- | --- | | `state` | `current`, `queued`, `compacting`, `building`, `publishing` or `blocked`. | | `head_seq` | The newest commit. | | `target_seq` | The commit publication is working toward. | | `published_seq` | The newest published commit. | | `lag_commits` | Commits written but not yet published. | | `current_stage` | The step in progress, when one runs. | | `last_progress_at_micros` | When publication last made progress. | | `retry` | `retry_after_ms`, `eventual_read_available` and a `message`. | Wait until `state` is `current` and `published_seq` is at least your `committed_commit_seq`. The SDKs do this with `waitForPublished(seq)` and `wait_for_published(seq)`. Both fail on `blocked` and after 30 seconds by default. Pass `timeoutMs` or `timeout` for a large load. A read can also skip the wait. `consistency=strong` applies the newest writes at once. `min_indexed_seq` sets the oldest commit a read accepts. See [core concepts](https://docs.littlebigbrain.com/start/concepts/#when-a-read-sees-your-write). ### Query the data Query with the IRIs from your data. This query lists each work with its author: ```sparql PREFIX foaf: PREFIX dcterms: SELECT ?title ?author WHERE { ?work dcterms:title ?title ; dcterms:creator ?person . ?person foaf:name ?author . } ORDER BY ?title ``` In the code, `worksQuery` (`works_query` in Python) holds this query text. The snippets use the `lbb` client from [Load a file](#load-a-file). ### TypeScript ```ts const works = await lbb.sparqlRows({ query: worksQuery }); for (const row of works.rows) console.log(row.title, "by", row.author); ``` ### Python ```python for row in lbb.sparql(works_query): print(row["title"], "by", row["author"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=research" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \ -d "{\"query\": $(jq -Rs . < works.rq)}" \ | jq -r '.results | fromjson | .results.bindings[] | "\(.title.value) by \(.author.value)"' ``` For cURL, save the query as `works.rq`. Each example prints two lines: ```text Notes on the Analytical Engine by Ada Lovelace The Analytical Engine by Charles Babbage ``` SPARQL tools such as YASGUI, Protégé and RDFLib connect to the `/sparql` endpoint. They use the stack API key. See [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). ### Validate RDF writes A SHACL shape set in `reject` mode also checks RDF imports and SPARQL Update. The platform validates each write before it is stored. A violation returns `400`, and the graph does not change. The check covers the records the write can affect. That includes records the write does not name. Say a shape requires each donation's donor to have a name. A new statement about a donor is then checked against every donation that names that donor. The cost follows the size of the write. A large graph does not slow a small write. `POST /v1/schema/publish` reports in `write_enforcement` whether each shape can be checked this way. Two error codes belong to this check: | Code | Cause | What to do | | --- | --- | --- | | `shapes_not_write_enforceable` | The shape set has an `sh:sparql` constraint or a recursive shape reference. The affected records of a write cannot be bounded. | Publish the shape set in `warn` mode and read the conformance report. On an RDF-native graph, publishing such a set in `reject` mode returns this code. | | `shacl_write_validation_budget` | One write affects more than 250,000 records, or needs more than 2,000,000 index lookups to find them. | Split the write into smaller requests, or publish the shape set in `warn` mode for this load. | In `reject` mode, a bulk load moves at the pace of publication. Say more than 64 MiB of statements wait for publication. The next write then returns a retryable `429 strong_read_pending`. Retry after the `Retry-After` delay. ### Add RDF to a JSON-records graph A graph built from JSON records accepts RDF import too. The statements keep their source predicates, next to the links you declared in JSON. This example adds a description to the `Bug` type of the [quickstart](https://docs.littlebigbrain.com/start/quickstart/) graph: ### TypeScript ```ts import { LbbClient } from "@littlebigbrain/client"; const support = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support", }); const added = await support.graph("support").facts.importRdf( ' "A defect in the product." .\n', { idempotencyKey: "support-rdf-1" }, ); console.log("commit", added.committed_commit_seq); ``` ### Python ```python import os from lbb import LbbClient support = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support") added = support.graph("support").facts.import_rdf( ' "A defect in the product." .\n', idempotency_key="support-rdf-1", ) print("commit", added["committed_commit_seq"]) ``` ### cURL ```sh curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/import/rdf?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/n-triples" \ -H "Idempotency-Key: support-rdf-1" \ --data-binary ' "A defect in the product." .' ``` The graph stays `property_graph`. JSON commits keep working after the import. The import adds `Resource`, `RdfLiteral` and `RDF_TRIPLE` to the graph's schema. A later `define` without them fails with `400 ontology_identity_breaking_change`. Use `evolve` for later schema changes. ### Errors | Response | Cause | | --- | --- | | `400 rdf_native_graph` | A JSON record route on a graph whose first write was RDF. | | `400 reserved_namespace` | A statement names `https://littlebigbrain.com/s/…` as its subject, or a predicate in `https://littlebigbrain.com/ns#` or `https://littlebigbrain.com/ep/`. The platform writes those for link evidence and link properties. Nothing was stored. | | `400 invalid_request` | An empty body, an unsupported format, `graph_uri`, an unknown `edge_idempotency` value, or a parse error with `strict=true`. | | `400` with `request exceeds 67108864 byte limit` | The request is larger than 64 MiB. Split it. | | `400` with `schema validation failed in reject mode` | The write breaks a `reject` shape. Nothing was stored. | | `400 shapes_not_write_enforceable`, `400 shacl_write_validation_budget` | See [Validate RDF writes](#validate-rdf-writes). | | `429` with `Retry-After` | The platform is busy, or publication is catching up. Retry after the delay. | ### Related - [Query and validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/): SPARQL, SHACL shapes and the conformance report. - [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/): answer with facts that follow from your schema. - [Replay answers and history](https://docs.littlebigbrain.com/guides/time-travel-audit/): read a graph as it was at an earlier commit. - [HTTP API](https://docs.littlebigbrain.com/sdks/http-api/): every route and parameter. ## Authentication & endpoints URL: https://docs.littlebigbrain.com/start/authentication/ Source: docs-site/src/content/docs/start/authentication.md Description: Environments, per-environment API keys, bearer auth, base URLs, and how the hosted MCP OAuth flow differs from a static key. This page covers accounts, environments and API keys. Your application calls its environment with an environment API key. The console and the account API use your browser session. ### The two planes | Plane | Host | Auth | You use it to… | | --- | --- | --- | --- | | **Data plane** (`lbb-server`) | Environment-specific `endpoint_url` | Bearer **environment API key** | Write records, search, run SPARQL, manage graphs | | **API** | `api.littlebigbrain.com` | Browser session cookie | Manage accounts, environments, and keys | Applications almost always call the **data plane** with an environment API key. The [hosted console](https://cloud.littlebigbrain.com) uses the account API for sign-in and environment management. ### Creating an account Sign in or sign up in the [hosted console](https://cloud.littlebigbrain.com). After you sign up, little big brain creates your personal workspace. You are its administrator. To finish signup, review the [monthly plan](https://docs.littlebigbrain.com/start/billing/) and select **Subscribe**. Then add a card on Stripe's hosted page. The first monthly minimum is charged at once. On the Cloud plan, this is €19 including applicable VAT, or $21 plus applicable tax. Your workspace opens after payment succeeds. Then you can create environments and use the platform. If you cancel payment setup, signup stays pending and you can try again. Renewals fall monthly on your signup date. Account settings and sign-out stay available throughout. Existing workspaces keep their access. An organization invitation adds you to that account, with the role it assigns. You use the account's subscription, so each member needs no plan of their own. ### Invite people Owners and admins invite people from **Settings**. Select **Invite people** there or in the account menu. Enter one or more email addresses and choose a role: - **Member** uses the account's environments and changes nothing. - **Admin** also manages members, environments, keys and the organization. Each person gets an email with a link to join. The pending invitations list under the members. You can resend or revoke each one. ### Environments An **environment** is an isolated tenant. It has its own graphs, data and keys. Create environments in the console (**Environments** view) or with the account API. Each environment has a slug, such as `product-dev`. MCP URLs and admin calls use it. A slug is unique in one account, and another account can use the same slug. ### Environment API keys An environment API key is a bearer credential. It looks like this: ``` lbb_sk_live_XXXXXXXXXXXXXXXXXXXX # production environment lbb_sk_test_XXXXXXXXXXXXXXXXXXXX # test environment ``` Send it as a bearer token on every data-plane request: ```sh curl "$LBB_URL/v1/graph/summary?graph=main" \ -H "Authorization: Bearer $LBB_API_KEY" ``` The SDKs take the key when you create the client: ```ts const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY }); ``` ```python lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"]) ``` Each new environment starts with an **initial key**. An environment can have up to 10 active keys. Create one named key per service, machine or environment. You can then revoke each integration on its own. Each key can be revoked, including the initial key and the last active key. A environment with no active keys refuses environment-key authentication. Manage keys on the console's **Access keys** page. A secret is shown once, when you create it. The information icon beside the page title explains how to store and revoke keys. The key list reads the selected environment's keys directly, so it loads without delay. :::caution[Keep keys server-side] A `lbb_sk_live_…` key can read and write its whole environment. Keep it in server environment variables or a secret manager. Never ship it in a browser bundle or commit it. The console does not store raw API keys. If a key leaks, create a replacement, and update and check the integration. Then revoke the old key. If containment matters more than uptime, revoke it first. For agents, use the [hosted MCP OAuth flow](#hosted-mcp-oauth), so the key never leaves the server. ::: #### Where to store it - **Node / server:** `process.env.LBB_API_KEY`. - **Python:** `os.environ["LBB_API_KEY"]`. - **MCP (local):** the `LBB_API_KEY` env var in your editor's MCP config. - **CI / deploys:** your secret store. ### Compatible names Older APIs and integrations call an environment a `stack`. Existing management routes such as `/api/stacks`, response fields such as `stack_id`, the `X-LBB-Stack` MCP header, API key prefixes and DNS names retain their wire spelling. The console calls these resources environments. This terminology change does not move data, change key permissions or create a new tenant identity. Optional scoped read tokens are separate from environment API keys and hosted MCP OAuth. See [How to model your app](https://docs.littlebigbrain.com/start/model-your-app/) for their read-only contract and deployment requirements. ### Base URLs Applications must use the complete **tenant-plus-environment endpoint** from the console: `https://--.db.eu.littlebigbrain.com`. [Endpoints & base URLs](https://docs.littlebigbrain.com/start/endpoints/) explains the addressing. | Purpose | URL | | --- | --- | | Hosted environment example | `https://0abc1def--production.db.eu.littlebigbrain.com` | | API | `https://api.littlebigbrain.com` | | Hosted MCP endpoint | `https://mcp.littlebigbrain.com` | | Console | `https://cloud.littlebigbrain.com` | | Marketing site | `https://littlebigbrain.com` | A **self-hosted distribution** is planned. It will serve one fixed tenant and graph, with a token you set. ### Selecting a graph An environment has a default graph, usually `main`. To use another, pass the `graph` query parameter. In the SDKs, set the client's `graph` option or call `client.graph("name")`: ```sh curl "$LBB_URL/sparql?graph=support" \ -H "Authorization: Bearer $LBB_API_KEY" \ -H "Accept: application/sparql-results+json" \ --data-urlencode 'query=SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5' ``` ### Hosted MCP OAuth The hosted MCP endpoint at `mcp.littlebigbrain.com` uses OAuth 2.1. An MCP client with remote auth, such as Claude or Cursor, finds the flow by itself. It receives an authorization challenge, runs the browser sign-in and sends an access token. The server checks the token and confirms that your account owns the environment. Then it opens a short-lived session to the environment. Your machine never holds a little big brain key. The [MCP guide](https://docs.littlebigbrain.com/sdks/mcp/) has the details. #### Discover the issuer and endpoints The [MCP protected-resource metadata](https://mcp.littlebigbrain.com/.well-known/oauth-protected-resource) publishes `resource: "https://mcp.littlebigbrain.com"` and its `authorization_servers` array. Use the advertised issuer to fetch its `/.well-known/oauth-authorization-server` or `/.well-known/openid-configuration`. The issuer's JSON provides `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `grant_types_supported` and `response_types_supported`. Agents starting at the homepage can also follow the [OAuth discovery](https://littlebigbrain.com/.well-known/oauth-authorization-server), [OIDC discovery](https://littlebigbrain.com/.well-known/openid-configuration) and [protected-resource discovery](https://littlebigbrain.com/.well-known/oauth-protected-resource) aliases. They redirect to current metadata from AuthKit or the hosted MCP service. Validate the issuer and resource at their canonical metadata URLs. Use the MCP origin as the OAuth `resource` and token audience; your environment is selected by the MCP URL or `X-LBB-Stack` header. The marketing origin is only an entry point for discovery. Environment HTTP APIs and developer integrations use the API keys described above and in the [integrations guide](https://docs.littlebigbrain.com/guides/integrations-for-your-customers/). #### Auth.md agent registration Read [auth.md](https://littlebigbrain.com/auth.md) for the current registration instructions. While WorkOS Agent Registration is disabled, it describes the existing human OAuth flow. When the issuer advertises `agent_auth`, the site serves WorkOS's generated claim and credential instructions with the MCP resource and environment-selection guidance. A claimed agent needs the trusted `mcp:access` permission. It grants read and write access through MCP to environments owned by the delegated user's organization. Every request checks the credential's signature, issuer, expiration and MCP audience, then confirms current provider validity, a completed service-auth claim, organization binding and the user's active membership. A cached internal database session cannot bypass these checks. Anonymous or unclaimed agents and provider agent API keys are refused; missing permission returns `403 insufficient_scope`. The agent permission is separate from the standard identity scopes used by human OAuth clients. Dynamically registered clients should request only the issuer's supported identity scopes. Revoke an agent in WorkOS **Authentication → Agents → Registrations**. After revocation the next MCP request is refused. ### Error responses Every non-2xx response has a JSON body of the form `{"error": {...}}`. The object holds `type`, `code`, `message`, `param`, `request_id` and `doc_url`. A retryable error also has `retryable: true` and `retry_after_seconds`. The SDKs raise the error as `LbbError`, with the HTTP `status` and the same values. Use `code` in your code, for example for a `404` on a missing graph. [Responses and errors](https://docs.littlebigbrain.com/sdks/http-api/#responses-and-errors) lists the codes. Two errors come from the environment endpoint. `421 stack_endpoint_required` means the host is missing or malformed. `403 stack_endpoint_mismatch` means the key belongs to another environment. In both cases, copy `endpoint_url` from the console. ## Endpoints & base URLs URL: https://docs.littlebigbrain.com/start/endpoints/ Source: docs-site/src/content/docs/start/endpoints.md Description: Use the tenant-plus-stack endpoint copied from the console for every hosted data-plane request. Every hosted stack has one canonical data-plane endpoint: ``` https://--.db.eu.littlebigbrain.com ``` Copy the complete `endpoint_url` from the console's **Connect** view. Do not construct it from the stack name alone. - `tenant-short-id` is the account's immutable, eight-character lowercase Crockford Base32 identifier, for example `0abc1def`. - `stack-slug` is the account-scoped stack name, for example `product-dev`. Slugs can be reused by different accounts, but not twice in one account. The examples in these docs use: ``` https://0abc1def--product-dev.db.eu.littlebigbrain.com ``` ### What the endpoint selects The hostname selects the account and stack. The `?graph=` query parameter, or the matching SDK option, selects the graph. Your stack API key must belong to the account and stack named by the host. The endpoint stays stable if the stack's storage placement changes. A stack's internal storage identity is independent of its public slug, so renaming the slug never moves graph objects. ### The composite host is required Application traffic must use the composite host. A missing or malformed stack host returns `421 stack_endpoint_required`. Presenting a valid credential for another stack returns `403 stack_endpoint_mismatch`. In either case, copy the console-provided `endpoint_url`. ### TLS and health Every stack endpoint is HTTPS-only and covered by the cell wildcard certificate. Operational health checks use the exact hostname `https://health.db.eu.littlebigbrain.com/healthz`, which reports cell health only. Application traffic goes to the stack endpoint. ### Hosted MCP addressing Hosted [MCP](https://docs.littlebigbrain.com/sdks/mcp/) uses a path form: ``` https://mcp.littlebigbrain.com/mcp/ ``` The slug is resolved within the WorkOS organization established by OAuth, so two organizations can use the same slug without colliding. ### Base URL summary | Purpose | URL | | --- | --- | | Hosted stack | `https://--.db.eu.littlebigbrain.com` | | Hosted MCP | `https://mcp.littlebigbrain.com/mcp/` | | SaaS API | `https://api.littlebigbrain.com` | | Console | `https://cloud.littlebigbrain.com` | | Cell health | `https://health.db.eu.littlebigbrain.com/healthz` | Next: run the [Quickstart](https://docs.littlebigbrain.com/start/quickstart/) or review [Authentication & endpoints](https://docs.littlebigbrain.com/start/authentication/). ## Usage & billing URL: https://docs.littlebigbrain.com/start/billing/ Source: docs-site/src/content/docs/start/billing.md Description: Current little big brain Cloud pricing starts at €19/$21 per month and €0.45/$0.49 per GiB-month. Understand storage, included queries, payments, and retained plan terms. Your plan page shows the prices you accept before subscribing, and the Billing page shows what you are being charged. Existing subscriptions keep the prices and schedule they accepted. Your billing country determines the account currency; the EUR/USD control on the plan page is a preview. Billing names your accepted plan and shows prepaid balance and credits as deductions from usage. **Extra usage so far** is the amount above those deductions, separate from the monthly minimum already paid. A legacy plan's calendar-month usage table is labelled separately from its signup billing period. Usage totals show when they were sampled. The page refreshes them in the background and labels older totals while an update is in progress. On a first visit, or when a billing period changes, you may briefly see **Preparing usage**; the page checks again automatically. Once totals are available, they stay visible with their sample time while an update is in progress, including if that update is temporarily unavailable. Recent activity may still be processing. Invoices remain the authority for final charges. **Compare plans** loads plan details separately, so you can review plans and continue payment setup while usage totals are being prepared. New Cloud subscriptions use storage-only pricing. Existing resource and query-unit subscriptions keep their accepted prices until an agreed plan change. For a storage estimate, use the [pricing calculator](https://littlebigbrain.com/pricing/). For the release history, see the [September 19 pricing update](https://docs.littlebigbrain.com/changelog/cloud/#19-september-storage-only-cloud-pricing). Pro runs in the shared cloud and includes every feature: models, workflows, evals, files and integrations. A [dedicated deployment](https://docs.littlebigbrain.com/guides/pro-deployments/) is part of Enterprise. It runs in Europe, hosted by us, or in your own AWS, Google Cloud or Azure account. In your own account, infrastructure charges begin when you approve the launch plan there. ### Free plan Choose **Start on Free** when you set up a workspace. It requires a verified email and no card. Free includes one environment and 30 GiB of data, indexes and retained history, with no expiry, monthly query quota or automatic charges. One Free account is available per verified user. Free allows 300 requests per rolling minute across your account, graphs and API keys. Reads and writes both count. Status checks do not count: job status, graph activity, publication status, embedding progress and workflow status. There is no plan concurrency limit, and queries have a 10-second deadline. A failed admitted query counts. Excess requests return `429 free_query_rate_limited` and `Retry-After`. When the shared servers are busy, a Free account keeps at least 4 requests running at once. Extra requests then return `429 free_fair_share_busy` and use no query slot. Search by meaning is included: embeddings, their managed model, and searches. The vectors count toward the 30 GiB. Other managed models require Cloud. Billing shows storage and query allowances. At full storage, growth pauses; reads, exports and deletion remain available within query fair use. Physical cleanup frees storage, so deleting data can take time to restore capacity. Upgrade explicitly to Cloud when needed. Free stays active during checkout and pending payment, and earlier Free usage is never billed. **Billing → Compare plans** opens the plan page, which marks your current plan. A workspace that never paid can start on Free only before it has an environment. ### Switching from Cloud to Free Choose **Switch to Free** on the plan page. The switch takes effect at the end of the month you paid for. Cloud stays until then, and nothing is charged after that date. You can undo the switch until one day before it. A paid month that ends within a day makes the switch final at once. Your workspace must fit Free first: one environment and at most 30 GiB of stored data. The plan page names what is too much. A workspace on a calendar-month plan (v2), or with an environment from before Free existed, can contact hello@littlebigbrain.com to move. Unused credit ends with the paid plan. ### Monthly Cloud subscription Every current paid plan works the same way at signup. You pay the monthly minimum upfront to finish signup. Available operator credit can cover that first invoice; a card is still required even when the amount to collect is zero. The same minimum renews monthly on your signup date. It covers that period's usage, and usage above it is invoiced after the period ends. Unused prepaid balance expires at renewal. There is no annual commitment. Select **Add card and activate** when you have invoice credit, or **Subscribe to Cloud** otherwise, and follow the Stripe payment page. Stripe collects the card, billing address and tax information. The console never handles or stores card details. Your workspace opens when the first invoice is settled by payment or available operator credit; saving a card alone does not unlock it. If payment needs another step, the signup screen links to the invoice and updates automatically. Metronome rates usage and creates invoices. Stripe calculates tax, collects payment and provides receipts and payment management. ### Storage-only plan (v4) New subscriptions use this plan. One thing is charged: the storage you keep. All EUR amounts below include applicable VAT, including the storage rate and the prepaid balance. USD amounts exclude applicable tax. The applicable rate depends on your billing details; it is included within the advertised EUR amount. | Plan | Retained storage | Prepaid monthly minimum | | --- | ---: | ---: | | Cloud (EUR) | €0.45/GiB-month | €19 | | Cloud (USD) | $0.49/GiB-month | $21 | | Pro (EUR) | €0.54/GiB-month | €190 | | Pro (USD) | $0.588/GiB-month | $210 | One rate applies to every GiB-month. There are no tiers and no volume steps. Pro pays 1.2 times the Cloud rate. Amounts are calculated at that exact rate and rounded once on the invoice, so a fractional cent per GiB-month is never rounded up before the total. Storage includes indexes and history. It is integrated over time, with a GiB-month equivalent to one GiB retained for 30 days, and it is summed across all environments in your account for the billing period. Queries, imports and index builds are included. They carry no charge, and they do not draw down your monthly minimum. [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) stores vectors, and they count as storage like the rest of your graph: 7.3 KB to 7.8 KB per instance at 1,536 dimensions in two measured catalogs. **Billing** shows their size in each environment's storage; hover the size to see each graph and embedding. They cost what the rest of your storage costs, not an extra storage charge. Pro enables all product features, including Models, workflows, integrations and evals. Model usage costs the provider's base price **plus 10%**, and counts toward the **same €190 / $210 monthly minimum as storage**. For example, €54 of storage and €110 of model usage are covered by the €190 minimum. At €154 of storage and €110 of model usage, the month's total is €264: €190 prepaid and €74 after the period. Unused prepaid balance expires at renewal. Base model prices are in USD. For EUR billing, the currency conversion is fixed with the accepted Pro contract. Reported token usage includes input, output and cache reads/writes at their respective base rates. Character estimates, budget reservations and unpriced calls are not invoiced. Existing accepted contracts keep their terms until an explicit plan change. Cloud model usage retains its included monthly model budget. Queries, imports, index builds, cache and object requests carry no separate charge on either plan. Included work still has capacity limits: request size, execution deadlines, rate limits, and concurrency controls can reject or defer work. Included does not mean unlimited throughput; contact us if your workload needs dedicated capacity. For example, a Cloud account in EUR keeping 30 GiB-month of data pays its €19 minimum, which covers €13.50 of storage. The same account keeping 100 GiB-month pays €45: the €19 minimum plus €26 in arrears after the period closes. Cloud and Pro are both self-service. Pro gives you every feature, at the rates above. A new or Free workspace chooses it on the plan page; Free stays until the upgrade is paid. On Cloud, select **Upgrade to Pro**: a new Pro billing month starts immediately. Your card pays the full Pro minimum now and monthly from today. The Cloud period ends immediately; unused Cloud minimum is not refunded. Pro becomes active after payment is confirmed. Your environments and data are kept. Enterprise is scoped with you. ### Resource plan (v3) Existing v3 subscriptions retain these rates. All EUR amounts below include applicable VAT, including the usage rates and prepaid balance. USD amounts exclude applicable tax. For example, at 19% VAT, the €90 payment consists of €75.63 net and €14.37 VAT. | Meter | EUR | USD | | --- | ---: | ---: | | First 1,024 GiB-month of retained storage | €0.135/GiB-month | $0.15/GiB-month | | Additional retained storage | €0.072/GiB-month | $0.08/GiB-month | | Resource work | €0.27/hour | $0.30/hour | | Prepaid monthly minimum | €90 | $100 | Storage includes indexes and history. It is integrated over time, with a GiB-month equivalent to one GiB retained for 30 days. Graduated tiers apply once across all environments in your account and billing period. For each query, import or index build, resource work is the greater of its CPU-hours or attributable memory GiB-hours divided by two. Those amounts are added across operations, preserving fractions until the charge is calculated. Shared cache memory has no separate fee. There are no additional query-count or object-request charges. For example, one operation using one CPU-hour and another using four GiB-hours of attributable memory contribute three resource hours in total. Comparing the account's combined CPU and memory totals instead would undercount them. ### Query-unit plan (v2) Existing v2 subscriptions retain these rates, excluding applicable tax: | Meter | EUR | USD | | --- | ---: | ---: | | Storage | €0.35/GiB-month | $0.40/GiB-month | | Query units | €1.50/million | $1.75/million | | Monthly minimum | €90 | $100 | Query units cover reads and writes using request shape, independent of cache warmth. All environments share one account invoice and minimum. Existing early accounts also retain their original calendar-month schedule; prepaid subscriptions retain their signup-date schedule. Changing the plan offered to new customers does not move an existing subscription or bill historical usage retroactively. ### Spending limit Active prepaid subscriptions can optionally set a spending limit in **Billing**. It is off by default. The amount must cover your monthly minimum and applies to recorded usage across all environments before credits and prepaid balance. EUR limits include applicable VAT; USD limits exclude applicable tax. The limit measures what your plan charges for. On the resource plan that is storage and resource work together. On the storage-only plan it is storage, because storage is the only thing billed. Once recorded usage reaches the limit, new queries, imports and index builds pause. Running work can finish, storage continues accruing, and recent usage may still be processing, so the final bill can exceed the limit. This is a pause control rather than a hard invoice ceiling. Data deletion stays available. Raise or disable the limit to resume new work, or wait for the next billing period. Changes take effect within five seconds; queued jobs retry their admission shortly afterward. Only account owners and users with billing management permission can change it. ### Billing, credits and payment recovery Open **Billing** from the account menu at the bottom of the console sidebar. It shows the current period, usage, prepaid minimum, available credit and invoices. Storage-only subscriptions show one **Storage** table: what each environment stores now, its search vectors, and what that size costs per month. Resource subscriptions also show the work hours they are charged for. If provider rating is delayed, the page identifies its local usage estimate. For the prepaid plans it uses your signup billing period before prepaid balance and credits; EUR estimates include applicable VAT and USD estimates exclude tax. The v2 fallback uses the indicated calendar month and excludes tax. An estimate is never a finalized invoice. Some accounts receive operator credit with an expiry date. Add a card to activate your workspace. The credit pays invoices first, including the monthly minimum and extra usage; your card pays any remainder after credit is used or expires. The plan page shows what is due at activation. Existing usage-only promotional grants retain the terms shown on their plan. Owners and users with billing management permission can use **Manage payment** to update their card through Stripe; members can view account usage. After a failed renewal payment, a seven-day grace period allows Stripe to retry. Successful payment restores paid access. Data deletion remains available in every payment state. :::note[Staged availability] If payment setup is not enabled for your account, signup remains pending until the cohort is enabled and the subscription is confirmed. Account settings, sign-out and workspace switching remain available. The plan you accept determines your prices and schedule. ::: ## Changelog URL: https://docs.littlebigbrain.com/changelog/ Source: docs-site/src/content/docs/changelog.mdx Description: Product updates for little big brain Cloud and the database engine. Follow updates to the hosted console and accounts, or changes to the database, query APIs, and developer tools. - [Cloud](https://docs.littlebigbrain.com/changelog/cloud/): Console, Explorer, accounts, access, and billing. - [Engine](https://docs.littlebigbrain.com/changelog/engine/): Queries, data import, validation, reasoning, performance, and SDKs. ### Latest in Cloud **4 October 2026:** Free now includes 30 GiB, up from 10 GiB, for every Free account. [Read Cloud updates →](https://docs.littlebigbrain.com/changelog/cloud/) ### Latest in Engine **4 October 2026:** Questions in plain words become SPARQL queries. The server describes your graph, writes one checked query, and can run it. [Read Engine updates →](https://docs.littlebigbrain.com/changelog/engine/) ## Cloud changelog URL: https://docs.littlebigbrain.com/changelog/cloud/ Source: docs-site/src/content/docs/changelog/cloud.md Description: Updates to the hosted console, Explorer, accounts, access, and billing. Updates to your hosted little big brain workspace. For database behavior and developer tools, see the [Engine changelog](https://docs.littlebigbrain.com/changelog/engine/). Entries describe the terms and behavior at release time. For today's prices, see [Usage & billing](https://docs.littlebigbrain.com/start/billing/). ### October 2026 #### 8 October: A dashboard on Home **Home** now shows the selected graph at a glance. The top panel gives its state and its size: instances, classes, statements and commits. A chart shows the month's model work per day, by job. Each feature has a tile with one live figure. Examples are searchable classes, open suggestions and the share of checked answers that were right. The largest classes and the newest background work close the page. #### 8 October: Upload files from Integrations **Integrations** now holds every way data comes in. **File upload** sits beside the connectors and takes Turtle, N-Triples or NDJSON. It works on every plan, also where connectors are off. The separate **Data** page is gone, and its old link opens Integrations. #### 8 October: A guided first run A new workspace opens on four steps: environment, graph, data and first questions. Each step offers its options, such as a source, a starter or a file. A workspace part way through sees the same steps above its dashboard. When a feature cannot run, the page says why and what to do next. Reasons include a step still to do, a Cloud feature on Free, or a Free limit. On Free, the sidebar shows your storage against the 30 GiB limit. #### 8 October: Clearer navigation The sidebar groups the pages by the work: **Graph**, **Sources**, **AI**, **Operate** and **Developers**. Each page names its group above its title. **Status** shows a dot while the graph updates or needs attention. **Knowledge graph** shows the count of open suggestions. The command palette (⌘K) uses the same groups. It also finds a page by what it does. "sparql" finds Query, and "api key" finds Access keys. A **Docs** link sits at the foot of the sidebar. #### 4 October: Free includes 30 GiB Free now includes 30 GiB of data, indexes and retained history, up from 10 GiB. Every Free account gets the new allowance, including accounts that started on Free before this change. The other Free terms stay the same. See [Usage & billing](https://docs.littlebigbrain.com/start/billing/#free-plan). #### 4 October: A Rerank switch beside the search box In **Knowledge graph**, each search box has a **Rerank** switch. It turns the rerank on or off for the graph. Jev then puts first the hits that answer your words. A line above the hits says their order, and each hit shows its relevance. **Models** has a new **Search rerank** page with the month's ranked hits and each graph's setting. See [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/#rerank-the-best-hits). #### 2 October: Start a graph from a starter A starter is a ready ontology for a domain: CRM, Documents or Work management. Each one holds classes, relations and the questions it answers. **Knowledge graph** has a new **Starters** tab with each starter and its status on the graph. **Apply** first shows what the starter adds. It never removes or changes what the graph holds. **Create graph** can start from one or more starters. A synced source starts from its domain's starter: HubSpot from CRM, Linear from Work management, SharePoint from Documents. See [Ontology starters](https://docs.littlebigbrain.com/guides/ontology-starters/). #### 2 October: Search by meaning on Free Free now includes search by meaning. Turn it on for a class in **Knowledge graph**. The platform embeds the records with its managed model, at no charge. The vectors count toward the 10 GiB. See [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). #### 1 October: A free plan Start on **Free** with no card. Free includes one environment and 10 GiB of data, indexes and retained history. It has no expiry and no automatic charges. It allows 300 requests per rolling minute, reads and writes together. Choose **Start on Free** when you set up a workspace. Upgrade to Cloud when you need more; your data and keys stay. Managed models require Cloud. See [Usage & billing](https://docs.littlebigbrain.com/start/billing/#free-plan). #### 1 October: Choose how a graph starts **Create graph** now asks how to start. Import a file, write from code, or design the ontology. Where integrations run, you can also sync a source. LBB then proposes the ontology from the source's records. The console opens the page for your choice. The other ways stay open later. ### September 2026 #### 30 September: See how a query runs Turn on **Profile** in **Query** to see how a query ran. The profile shows the time per stage, the blocks and bytes read, and the join order the planner chose, with its estimate for each pattern. See [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). **Query planner**, under Operate, shows the statistics the planner uses for a graph: the triple count of each predicate and the indexes that help it plan. It can build the value-order index. The profiles you ran in the tab are listed there too. #### 28 September: Strong reads by default in Query **Query** now runs strong reads by default, so a query sees your latest writes. A long queue of unindexed commits makes a query slower, not refused. #### 28 September: Search by meaning for every account [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/) is now on for every account. Turn it on for a class in **Knowledge graph**. #### 28 September: A shorter Billing page **Billing** has less text. Storage-only plans show one **Storage** table: what each environment stores, its search vectors, and the cost per month. The page and **Status** also load faster. #### 28 September: Rules you can read The **Validation** tab shows each SHACL rule as a short sentence, grouped by class. An example: "Each Service has at least 1 tier". Each rule shows how many entities break it. Open it to see them by name. A class card and a property page list the rules that apply to them. A red dot marks a class or property with failing entities. On **Status**, the current search classes fold into one list with a filter. #### 28 September: Knowledge graph in the sidebar Ontology is now **Knowledge graph**. Its first tab, Browse, is now **Ontology**. **Status**, **Environments** and **Graphs** have sidebar items. **Data** left the sidebar. Open it from Home, Status or Graphs. #### 27 September: See the work on a graph A dot beside the graph name shows the graph's state. Select it to open **Status**. Status shows how far the newest commit has reached: written, indexed, and searchable. It lists running work: index updates, search embeddings, and imports. Recent finished work stays below. #### 26 September: Browse the graph in Ontology Explorer is now the **Browse** tab of Ontology. The class tree sits on the left. A class sits on the right with its links on either side. An instance opens as a record, with its values and relationships. Old Explorer links open the same view. #### 19 September: Storage-only Cloud pricing New Cloud subscriptions pay a €19 monthly minimum and €0.45 per GiB-month, including applicable VAT, or $21 and $0.49 per GiB-month plus applicable tax. Storage includes indexes and retained history. Queries, imports, and index builds are included, with no separate usage charge. The minimum is prepaid and covers storage for the billing period; storage above it is invoiced after the period ends. Existing resource and query-unit subscriptions keep their accepted prices and schedule until an agreed change. See [current prices and examples](https://docs.littlebigbrain.com/start/billing/#storage-only-plan-v4). #### 10 September: Resource pricing and VAT-inclusive subscriptions New Cloud subscriptions pay €90 including applicable VAT or $100 plus applicable tax upfront, then monthly on the signup date. Usage draws from that monthly minimum, with account-wide storage tiers and resource work across queries, imports and index builds. Resource plans have no separate query-count charge. The signup screen shows the accepted prices and first-payment progress. Existing subscriptions retain their current rates and schedule. See [Usage & billing](https://docs.littlebigbrain.com/start/billing/) for the resource-hour formula, spending controls and payment recovery. #### 4 September: Clearer navigation and account usage Graphs and environments are accessible from the switchers at the top of the sidebar. Billing and Settings are in the account menu at the bottom. Billing shows storage and query-unit usage by environment and for the account, with the rate and subtotal for each. You can review your plan, credits and their expiry dates, charges, and latest invoice in one place. Account owners and admins can review plan terms before continuing to payment setup when it is available for their account. See [Usage & billing](https://docs.littlebigbrain.com/start/billing/) for current plans and availability. #### 3 September: Shared cache is included Caching is managed automatically and has no separate charge. Account usage includes storage and query units. #### 2 September: Account-wide billing visibility The Billing page brings together usage across your environments, account credits, and invoice status. Owners and admins can manage payment details; members can view account usage. ### August 2026 #### 31 August: Typed properties in the Ontology workbench Imported ontology data properties appear as fields with their native value types. This makes text, numbers, booleans, and dates easier to distinguish from relationships between classes. #### 28 August: Browse the ontology hierarchy The Ontology screen shows each class with its direct parents and children. You can browse instances through the class hierarchy and inspect how classes relate to one another. #### 23 August: Explore RDF data through classes and relationships Explorer connects observed classes through their relationships. The instance inspector shows RDF fields and grouped relationships, with one-hop expansion and traversal between related entities. When data is still being indexed, the console provides clearer retry guidance. #### 22 August: Faster class browsing and simpler administration Opening a populated class loads a sample of its instances, keeping Explorer responsive on larger datasets. Graph cards are directly selectable, and owners and admins can manage members and access keys. ### July 2026 #### 27 July: Sign-in during large imports Account sign-in stays responsive while data is being indexed. #### 24 July: Navigate between classes and instances Explorer lets you move from ontology classes to their instances and back, with related entities grouped by relationship. #### 21 July: Choose read consistency in the console Query and Explorer let you choose read consistency when you need newly written data to be visible. See [Core concepts](https://docs.littlebigbrain.com/start/concepts/#when-a-read-sees-your-write). #### 20 July: Revoke access keys Revoking a stack access key takes effect immediately. #### 16 July: Stack endpoints and guided onboarding Each stack has its own endpoint, ready to copy into your application. You can create named access keys, and a first-run checklist guides you from stack creation to your first query. See [Endpoints & base URLs](https://docs.littlebigbrain.com/start/endpoints/) and [Authentication & keys](https://docs.littlebigbrain.com/start/authentication/). #### 10 July: Import Turtle files The console accepts RDF Turtle files directly. #### 5 July: Browse with your account session Your signed-in console session can read stack data without entering an access key in Explorer. #### 2 July: Redesigned console The console adds reorganized navigation, a visual ontology editor, and guided setup for a new stack. ### June 2026 #### 19 June: Connect MCP with your account Connect an MCP client using account sign-in. Agents can use the stack's query and write tools without handling raw access keys. See [MCP for agents](https://docs.littlebigbrain.com/sdks/mcp/). ## Engine changelog URL: https://docs.littlebigbrain.com/changelog/engine/ Source: docs-site/src/content/docs/changelog/engine.md Description: Updates to queries, data import, validation, reasoning, performance, and SDKs. Updates to the database engine, query APIs, and developer tools. For the hosted console, accounts, and billing, see the [Cloud changelog](https://docs.littlebigbrain.com/changelog/cloud/). ### Unreleased #### Questions get 60 seconds `POST /v1/query/ask` now stops at a 60-second read deadline. Other reads keep 25 seconds. A question that needs several model turns and queries now has time to answer. While the model writes a turn, the question holds no server slot. Other requests can run in that time. If the server stays full after a turn, the loop stops and returns the best rows it read. Its `error` says the server stayed busy. No client change is needed. The SDKs already wait up to 120 seconds. #### Keep a generated file `DELETE /v1/files/expiry?sha256=` removes the `expires_at` of a generated file, one that a workflow run wrote. The file then stays until a person deletes it, and `GET /v1/files` lists it. A repeat answers the same record. An upload of those bytes answers `409 file_generated`. The bytes are not a source. No route writes a generated file yet. #### Commits stay inside what a workflow owns A commit under a workflow turn must write only the properties its workflow lists in `owns.properties`. A property outside them answers `403 write_outside_owns` with the IRIs in `details`, and nothing is written. A `ValueSource` entity may carry `property`, `value` and `support` whoever the owner is. A commit with `property_merge: "replace"` is refused too. Replace erases fields of the entity that the workflow may not own. At first the server writes such a commit as before. It only counts what it would refuse. No client change is needed. #### Check proposed values against their files `POST /v1/workflows/activities/ground` checks values that a model proposes. Each item names a file, a place in it, a property, a value and a quote. The server answers one verdict per item, in order, with no model call. - **Checks.** The file is live and its upload is the one the item read. The place exists. The quote is in the text there. The property is in the ontology. The value takes its type, and a number comes in the unit of its property, converted with a fixed factor. - **Places.** `page=N` for a parsed PDF, `line=a,b` for text and Markdown, and `row=a-b` for CSV and TSV. - **Evidence.** A grounded item carries the typed value and the evidence of its quote, with the box of the lines on a PDF page. - **Bounds.** A request holds 16 items from 8 files. It scans 4 MiB, and an item scans 1 MiB of its file. A PDF's stored pages may hold 16 MiB. A place past a bound gets `scan_budget`. A request past 10 seconds answers `503 ground_timeout` with no verdict. - **Values.** A value is at most 100 bytes and a unit 24 bytes. Only a number is read apart from its unit. A text keeps its words. - **Open to all.** The route calls no managed model, so Free accounts use it. #### Rust source convenience wrappers Remove the uncalled Rust helpers `lbb_fts::search_bm25` and `GraphStore::warm_object`. This is a downstream Rust source-API break for Git, path or other crate consumers; external use is unknown. The retained `Bm25Index::build(documents)?.search(query, top_k)` performs the same legacy BM25 operation. Current operator warming keeps its bounded graph plan; it is not equivalent to an arbitrary-key whole-object read. No HTTP/SDK route, package version, persisted format, current RDF/class embedding search or shared ANN/reranking/Ask behavior changes. This source notice does not announce package publication, deployment or completion of a retirement window. ### October 2026 #### 28 October: Hosted workers parse files in a parse slot On a hosted cell the workers no longer start `pdftotext` and `pdfimages`. Each worker process has a parse slot: a container with its own user, its own process view, no network, a read-only root and a 16 MiB work directory. The slot runs the program with an empty environment, no capability, and limits on memory, CPU time, file size, open files and processes. It kills the program at its wall limit and empties the work directory after every file. - **Checked result.** The worker checks what the slot returns before it stores a page: valid UTF-8, at most 2,000 pages in order, a page size that is a number of points, line ids and texts that match their words, and boxes from 0 to 1. A result that fails a check is a parse failure and stores nothing. - **Output cap.** A file whose text output passes 128 MiB now fails with `the parser wrote more than 134217728 bytes`. The cap was 256 MiB. - **New settings.** `LBB_PARSE_MODE` and `LBB_PARSE_SLOT_DIRS` for the worker. `LBB_SERVING_PARSE` for the server: `off` makes the serving, ingest and maintenance processes start no Poppler. It defaults to `on`. - **Roll order.** Roll the pool: the slots start first, then the workers. Turn `LBB_SERVING_PARSE` off only after the workers parse. `roll-cell.sh` refuses a deploy that does not set `LBB_WORKER_WORKFLOWS` to include `documents.parse`. - **Not covered yet.** The XLSX reader still runs in the serving process. #### 27 October: Read XLSX workbooks by sheet and cell `GET /v1/files/sheets` serves an XLSX workbook. It gives the outline of the sheets, or the rows of one sheet by A1 row. See [Spreadsheets](https://docs.littlebigbrain.com/guides/documents-to-graph/#spreadsheets). A cell has the value its file stores and its formula apart. LBB calculates nothing. - **Bounded read.** The first read parses the workbook in a child process. The child has an empty environment. - **Hard limits.** A limit refuses the whole workbook with `xlsx_refused`. The limits cover the file, its expansion and its XML elements. They cover sheets, rows, columns, cells and cell text. They also cover the output and the CPU time. - **Few parses at a time.** A node parses two workbooks at once, and a tenant one. The parse ends with the read that asks for it. - **New codes**: `xlsx_refused`, `xlsx_busy`, `file_not_spreadsheet` and `sheet_not_found`. - **Places.** Evidence names a place in a sheet with `…#Sales!B2:D9`. The console opens the file at it. The `rows` template reads XLSX files and writes `#Sales!A5:F5` for a row. - **Server setting.** `LBB_PYTHON3` names the interpreter. The server image installs `python3`. - **SDKs.** `files.sheets` in TypeScript and Python. Helpers write and read the fragment of a place. #### 27 October: Grade the values no label judges Sometimes the arms of an experiment differ on a value that the dataset does not label. A person can now grade that value in the Review inbox. The answer becomes a label of the dataset ([Grade the values no label judges](https://docs.littlebigbrain.com/guides/experiments/#grade-the-values-no-label-judges)). Little Big Brain files these items only when the operator turns the feature on. - **New review origin** `evals:datasets`, kind `grade`, options `right` and `wrong`. `ReviewEvalOrigin` has the kind `dataset` and the fields `item`, `property`, `value` and `stated`. `eval_id` filters by dataset. - **New field** `source` on a label of a dataset item: `{kind, id, at}`. The `kind` is `test` or `review`. The latest answer of a person wins. A label without a `source` is never replaced. - **Changed scoring.** A property with only `leave_out` rows of values judges those values. Every other value stays unlabeled. Before, it was `wrong`. A report scored before this release reads as stale, because `labels_etag` now ends in `#g2`. The next score turn scores it again. - **New error code** `eval_dataset_items_too_large` (400): a label would make a dataset's items larger than 8 MiB. - **New flag** `LBB_REVIEW_EVAL_DATASETS` (`off` by default; needs `LBB_REVIEW_EVAL_ITEMS`). It gates filing, never an answer. - **New metrics** `lbb_review_eval_datasets_enabled{mode}` and `lbb_review_eval_items_waiting{origin="dataset", reason}`. The counter `lbb_review_eval_items_total` has the origin `dataset`. - **Roll first**: an earlier node reads a dataset item as `unknown`. It answers the item with no label and drops `source` from labels. Roll every node before you turn the flag on. #### 27 October: Scanned pages are flagged A scanned PDF page is a picture of text. Its text layer is empty or nearly so. The `documents.parse` workflow now flags such a page `ocr_required: true`. A page gets the flag when two things hold. It has fewer than 16 characters of text. Its images cover at least 40% of it. - **New fields**: `ocr_required` on a page of `GET /v1/files/pages`. Also `ocr_pages` in the `parse` of a file and on its `Document`. - **Parser id**: pages with the flag are stored under `poppler--scan1`. Pages parsed before keep their id and no flag. - **Console**: a page that needs OCR shows a warning. The file list shows how many pages need OCR. - **Run results**: with the Rust workflow, a file of which every page needs OCR is a result with no value. OCR itself is not built. - **New setting**: `LBB_PDFIMAGES` names the Poppler program that lists the images. The server image and the worker image already contain it. - **One file cannot hold the queue.** A file whose parse stops goes to the back. At its third stop it is stored as `failed`, with the reason. - **Roll order**: roll the server first, then the workers. An old server drops the flag and stores pages without it under the new id. The new server then answers `409` for the same pages with the flag. - **Parse programs**: `pdftotext` and `pdfimages` now run with an empty environment and a fixed `PATH`. No key or token of the server or the worker reaches them. They also run with limits on memory, CPU time and file size. Before, they inherited the environment of their host. The server set no limit. #### 27 October: Retry a failed reading once `values.extract` takes the parameter `repair`. With `repair: 1`, an input whose first answer fails a check gets one more attempt. The retry sends the same text and the failed checks. See [Retry a failed reading once](https://docs.littlebigbrain.com/guides/starter-workflows/#retry-a-failed-reading-once). `repair: 0` is the default and changes nothing. - **New parameter** `repair` (`0` or `1`). It is part of the settings revision. A test on a named revision reads it from there. - **New fields**: `attempt` on a logged call and on a call list row (`2` on the retry). `attempt` on a value of a test or run result. `settings.repair` on a test. `attempt` on a `reason` activity request. - **New counter** `retried` in the workflow's status. - **Roll first**: an earlier node refuses `repair` on a PUT. It also refuses `attempt` on a `reason` request. #### 27 October: See whether a revision was tested A revision of a `values.extract` workflow shows whether a workflow test read inputs with exactly its settings ([See whether a revision was tested](https://docs.littlebigbrain.com/guides/workflow-settings-history/#see-whether-a-revision-was-tested)). - **New field** `last_tested` on `GET /v1/workflows/named/revisions` and `…/revisions/get`: `{test, finished, counts}` for the newest test that counts, `null` when no test is recorded for these settings, and no key for a workflow without tests. An experiment's arm is such a test and adds `experiment` (`id`, `arm`). It shows what happened and gives no verdict. Activation is never refused. - A test counts when it ended `done` and the model read at least one input. Tests from before this release do not count. In a model experiment only arm A records a fact, because B runs another model than the workflow's. - The console marks each row of the history and warns before it uses settings with no recorded test. #### 26 October: Compare two versions of a workflow An **experiment** runs a `values.extract` workflow twice on the items of a dataset: as it is (A) and with one change (B). B is the model of one step (`extract` or `decide`) or a kept settings revision. Each output is checked against the dataset's values. The report gives the difference with a 95% interval ([Compare two versions of a workflow](https://docs.littlebigbrain.com/guides/experiments/)). - **New routes** `POST /v1/evals/experiments`, `POST /v1/evals/experiments/estimate`, `GET /v1/evals/experiments`, `GET /v1/evals/experiments/get` and `POST /v1/evals/experiments/cancel`. - **New fields** `experiment` on `PUT /v1/models/choices` and on a revision activation. The write applies the experiment's B, and the server refuses it with `experiment_outdated` when the workflow no longer runs A. - **New codes** `experiment_arms_mixed`, `experiment_outdated` and `experiments_instance_taken`. - An experiment writes nothing to the graph and changes no workflow. Its model calls count in the environment's monthly workflow budget, as a test's do. #### 22 October: Choose the model of each workflow step The AI steps of workflows can run another model, for the whole environment or for one workflow ([Choose the model of a workflow step](https://docs.littlebigbrain.com/guides/check-model-quality/#choose-the-model-of-a-workflow-step)). `extract` is the reasoning model (`values.extract` reads values, the `reason` activity), and `decide` the decision model (the support of a value, the `decide` activity). - **New routes** `GET`, `PUT` and `DELETE /v1/models/choices`, with the codes `model_choice_unsupported` and `model_choice_unavailable`. - **New fields**: a logged call's `choice` (why it ran its model), `models` on a test create to test other models, and a test's `models`. - A run's fingerprint names its models, so runs before and after a change have different fingerprints. #### 22 October: A workflow's settings keep their history Each change of a workflow's settings is a revision, named by a hash of the settings: the same settings always get the same id. A workflow's history lists who changed its settings, when, and what changed, and an earlier revision can be used again ([Settings history of a workflow](https://docs.littlebigbrain.com/guides/workflow-settings-history/)). - **New routes** `GET /v1/workflows/named/revisions`, `GET /v1/workflows/named/revisions/get` and `POST /v1/workflows/named/revisions/activate`, with the codes `workflow_revision_conflict`, `workflow_revision_stale` and `workflow_revision_not_found`. - **New fields**: a workflow's `active_revision`, a run summary's and a test's `revision`, and `revision` on a test create, to test earlier settings without using them. - `PUT /v1/workflows/named` and `PUT /v1/workflows/triggered` keep their bodies and answers. #### 21 October: Every error code has an entry, and a lost turn lease has its own code Each error body's `doc_url` now links to the code's entry on [Error codes](https://docs.littlebigbrain.com/sdks/errors/): its status, its type, what it means and what to do. Each code the data plane sends has one entry, and CI checks it. What changes in the error bodies: - **New code `turn_lease_lost`** (409, `conflict_error`, `retryable: false`, `param: "token"`). A `/v1/workflows/turns/*` route answers it when the token no longer holds the turn: another attempt holds it, its lease ended, it waits after a sleep or signal checkpoint, or it finished. Before, these answered `409 conflict`. The SDK workers stop on both codes. A worker that calls these routes itself and stops on `conflict` must stop on `turn_lease_lost` too. - **A 402 without a code of its own** answers `quota_exceeded` (`quota_error`). Before, its code was `api_error`, the name of a type. - **`param`** names the field to change on more errors: a request body's missing, unknown or doubled field, a missing query parameter of the workflow, starter and evals routes, the review list's `cursor`, and the review answer's `Idempotency-Key`. - **`request_id`** is in every error body, and the `X-Request-Id` header has the same value, also on a request refused before it reaches a route (a bad header, a busy server). A path that no route serves answers `404 not_found` in the error shape, not as text. No code changed its type: a code keeps the type it was first sent with. #### 5 October: Questions get one route, `POST /v1/query/ask` `POST /v1/query/ask` answers a question in plain words. A model runs queries in a short loop, reads the rows, and answers with the records it cites. ```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?"}' ``` This change breaks clients of `POST /v1/query/rewrite`: - `POST /v1/query/rewrite` is gone. It answers 404 and names the new route. - The one-shot mode is gone: `mode: "rewrite"`, `run` and `previous` answer 400. The loop always runs its queries and corrects them itself. - `mode: "route"` stays: the route alone, in about 0.3 seconds. - The stream events `query`, `run`, `rows` and `repair` are gone. The stream sends `step` per tool call, then `answer`. - `history.before` and `history.after` are gone. A comparison fills `history.added`, `history.removed` and `history.changed`. - The loop's steps include `compare`, for "what changed" questions, and a `describe` that shows how many records hold each property and the values of small classes. The SDKs change with it. TypeScript has `query.ask` and `query.askStream`, Python `query.ask` and `query.ask_stream`. `query.rewrite` and the rewrite streams are gone. MCP `lbb_query mode=question` always answers. To write queries in your own loop, use [the tools](https://docs.littlebigbrain.com/guides/answer-questions/#use-the-tools-in-your-own-agent). #### 5 October: Rerank inside a SPARQL query A SPARQL search can now rerank its hits. Add `search:rerank true` to the search triples. - The rerank model reads the best hits with the query text. The query keeps the hits that answer it best. - `search:relevance ?r` binds the model's answer, from 0 to 1. Order by it with `ORDER BY DESC(?r)`. - A query reranks only when it asks. The graph's rerank setting applies to `POST /v1/search` only. - See [Rerank inside a query](https://docs.littlebigbrain.com/guides/search-by-meaning/#rerank-inside-a-query). #### 4 October: Rerank search hits A search can now rerank its best hits. Jev, a decision model, reads each hit with your words. The hits that answer them come first. - Turn it on for a graph with `PUT /v1/search/settings` and `{"rerank": true}`. One search can set `rerank` for itself. - Each hit gets `relevance`, the probability that it answers the words. `score` stays the similarity. - On BEIR SciFact, nDCG@10 rose from 0.713 to 0.797 over 300 test queries. A rerank adds about 0.3 s. - A rerank never fails a search. Without an answer in time, the hits keep the similarity order. - The SDKs add `searchSettings()` and `setSearchSettings()` (`search_settings()` and `set_search_settings()` in Python). MCP's `lbb_query mode=search` takes `rerank`. See [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/#rerank-the-best-hits). #### 4 October: Questions in plain words become SPARQL queries `POST /v1/query/rewrite` turns a question into one checked SPARQL query. With `run: true`, it also runs the query and returns the rows. ```sh curl -X POST "$LBB_URL/v1/query/rewrite?graph=services" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"question": "How many services does each team run?", "run": true}' ``` - The server describes your graph: classes, properties, schema statements and the classes you can search by meaning. - TypeSafe's Jev picks the kind of query: lookup, aggregate, search, history, schema or unanswerable. Claude Sonnet 5.5 writes the query. - The server checks the query and adds missing `PREFIX` lines. A query that fails is corrected once. - A run records an eval trace with the question as its request. - `previous` carries earlier attempts, `context` carries your app's notes, and `mode: "route"` returns the kind of query only. - TypeScript and Python add `query.ask` and `query.rewrite`. MCP `lbb_query` adds `mode: "question"`. LBB runs both models with its own keys. The question, your notes and a description of the graph, with short example values, go to the model providers. See [Answer questions in plain words](https://docs.littlebigbrain.com/guides/answer-questions/). #### 3 October: Search by meaning inside SPARQL A SPARQL query can now search by meaning. Any other pattern on the hit filters it: a link, a value, a date or a type. ```sparql PREFIX search: PREFIX dr: PREFIX rdfs: SELECT ?x ?score WHERE { ?x search:similarTo "card payments" ; search:score ?score . ?x dr:calls ?callee . ?callee rdfs:label "payment-service" . } ORDER BY DESC(?score) LIMIT 5 ``` - The query is a text, a record ("more like this") or your own vector. - The engine picks a plan by cost. A narrow filter is read first, and only its records are scored. A broad filter is applied to the nearest part of the index. - The response's `search` object names the plan, both costs and the time of each phase. - `as_of_commit_seq` reads the graph at an older commit. - `FILTER(?score > 0.8)` keeps only close hits. The syntax is standard SPARQL, so existing clients send these queries unchanged. See [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/#search-inside-a-sparql-query). #### 3 October: MCP 0.8.1 `suggest_ontology_change` takes up to 128 operations in `change`, the same limit as the server. MCP 0.8.0 refused more than 64. #### 3 October: `EXISTS` under aggregates and in `OPTIONAL` filters Under an aggregate, a `BIND` with `EXISTS` left its variable unbound. This query returned 0. Now it counts the users that are not persons: ```sparql SELECT (SUM(?p) AS ?n) WHERE { ?x a ex:User BIND(IF(NOT EXISTS { ?x a ex:Person }, 1, 0) AS ?p) } ``` Two other shapes returned an error. One is `EXISTS` inside a larger `FILTER` under an aggregate. The other is `EXISTS` in the filter of an `OPTIONAL`. Both shapes now run. #### 2 October: TypeScript and Python 0.17.0, MCP 0.8.0 TypeScript `@littlebigbrain/client` and Python `littlebigbrain` 0.17.0 add the [ontology starters](https://docs.littlebigbrain.com/guides/ontology-starters/). `ontology.starters` lists, applies and updates `crm`, `documents` and `work`. Typed constants give each starter term its SPARQL IRI. A refusal now carries the reasons for each item in `LbbError.details`. An example is the `conflicts` of `409 starter_conflict`. Version 0.16.0 came out the same day. It added these methods: - `ontology.suggestions` reads and decides [ontology change suggestions](https://docs.littlebigbrain.com/guides/ontology-suggestions/). - `query.sparql` takes `profile` and returns how the query ran. See [See how a query ran](https://docs.littlebigbrain.com/guides/query-profiles/). - `plannerStats` returns the statistics of the query planner. - `modelActivity` reports what each managed model did in one month. - `workflows.deleteInstance` deletes a workflow instance and its history. Python uses snake case names, such as `planner_stats`. Version 0.16.0 drops `ontology.induce`, whose route the server no longer has. The Python client now leaves unset optional fields out of a model request body. It sent them as `null`, and the server refused some requests with HTTP 400. MCP 0.8.0 adds `list_starters`, `apply_starter` and `suggest_ontology_change` to `lbb_configure`. `lbb_inspect` lists suggestions with `action: "ontology_suggestions"`. #### 2 October: `xsd:dateTime()` casts a string The cast `xsd:dateTime("2026-10-01T00:00:00Z")` left its variable unbound. Now it returns `"2026-10-01T00:00:00Z"^^xsd:dateTime`, the same value as `STRDT`. The casts `xsd:date()` and `xsd:time()` work too. - A string casts to each of the three datatypes. - An `xsd:dateTime` casts to `xsd:date` and `xsd:time`. - An `xsd:date` casts to `xsd:dateTime`, at the start of that day. - `xsd:string()` of a date or time returns its canonical form. - An invalid value, such as month 13, is a type error. A `BIND` stays unbound. Every cast now ignores spaces around a string, as XPath prescribes. So `xsd:integer(" 42 ")` returns 42. #### 2 October: Entailment applies inside `EXISTS` and `NOT EXISTS` With `entailment=subclass`, `rdfs` or `owl`, the pattern inside `EXISTS` matched only stored statements. The main pattern and `MINUS` used the entailed graph. Now every pattern in the query uses the entailed graph. This includes an `EXISTS` in a `FILTER`, `BIND`, `IF`, projected expression or `HAVING`. Here `ex:User` is a subclass of `ex:Person`. The query now returns no users, the same answer as `MINUS { ?x a ex:Person }`: ```sparql SELECT ?x WHERE { ?x a ex:User FILTER NOT EXISTS { ?x a ex:Person } } ``` #### 2 October: A date with a very long year is stored A date with a year past 9223372036854775807 failed the write or the query. An example is `"99999999999999999999-01-01"^^xsd:date`. Now the write stores the value unchanged, and a query reads it back. The value is out of range, so a comparison or `YEAR()` treats it as a type error. #### 2 October: `GROUP BY` keeps a fixed subject or object A `GROUP BY` over one triple pattern ignored a fixed subject or object. This query returned each subject that has a type: ```sparql SELECT ?x (COUNT(*) AS ?n) WHERE { ?x a ex:User } GROUP BY ?x ``` Now it returns only the members of `ex:User`. The fix also applies to `?x ex:knows ?x`, which repeats the grouped variable. It applies to a count sorted with `ORDER BY` and `LIMIT`, and to two joined patterns. ### September 2026 #### 28 September: Search by meaning Search by meaning is now available for every account. Declare an embedding on a class. The platform builds one text per record from the fields you choose, embeds it with the graph's model, and keeps the vectors current after each published commit. A search embeds your question with the same model and returns the nearest records. Each hit is checked against one published commit, so a deleted or re-typed record does not come back. - `POST /v1/embeddings/preview` shows the text of sample records and the facts you can choose. It stores nothing and calls no model. - `PUT /v1/embeddings` declares an embedding and starts the embed job. `GET /v1/embeddings` lists the embeddings and their state, and `DELETE /v1/embeddings` removes one. - `POST /v1/search` searches every searchable class. A `filter` limits the hits to a class and its subclasses, or to records linked to a named record. With `"explain": true`, the search returns its plan and calls no model. - `PUT /v1/embeddings/model` moves every class of a graph to a new model. The graph switches when every class is ready, so a search never mixes models. The TypeScript and Python SDKs add `embeddings` methods, including `search`. The MCP server adds `lbb_embeddings`, `lbb_embeddings_manage` and `lbb_embeddings_delete`, and `lbb_query` gets `mode=search`. Vectors count as stored data; model tokens are not billed. See [Search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/). #### 27 September: `BIND(EXISTS …)` returns a value in a joined group In a braced group joined with other patterns, `BIND(EXISTS { … } AS ?e)` left `?e` unbound. Now `?e` is `true` or `false`. The `EXISTS` uses the variables that its own group binds. `NOT EXISTS` works the same way. ```sparql SELECT ?k ?e WHERE { ?k ex:memberOf ex:platform . { ?k a ?c BIND(EXISTS { ?k ex:knows ?o } AS ?e) } } ``` #### 26 September: `owl:sameAs` no longer makes a record a type With `entailment=owl`, `SELECT ?t WHERE { ex:alice a ?t }` listed `ex:alice` as a type. It also listed each `owl:sameAs` alias of `ex:alice`. The query now lists only classes. A `BIND` in a join could replace a value that another pattern bound. Now the two values must be equal, or the row is dropped. In an `OPTIONAL`, a left row with a different value keeps its value and stays unmatched. #### 26 September: JSON type hierarchies reach queries A type's `super_types` in a JSON schema publish one `rdfs:subClassOf` statement per parent. So does the `add_super_types` evolve operation. With `entailment=subclass`, `rdfs` or `owl`, a query for a type also matches its subtypes. `SELECT ?c ?p WHERE { ?c rdfs:subClassOf ?p }` lists the links. A strong read applies a new parent at once. An eventual read applies it after the graph republishes. Republication starts when the schema change is applied. See [Add a type hierarchy to JSON records](https://docs.littlebigbrain.com/guides/reasoning/#add-a-type-hierarchy-to-json-records). #### 25 September: Record-history routes removed These read routes are removed: - `GET /v1/graph/entity/metadata` and `GET /v1/graph/entity/neighborhood` - `GET /v1/graph/entities/sample` and `GET /v1/graph/changes` - `POST /v1/query/state`, `/v1/query/history`, `/v1/query/transitions`, `/v1/query/why` and `/v1/query/conflicts` They read data that hosted graphs do not publish. So they always answered `429 ingest_busy`, and a client retried them forever. They now return `404`. The `did_you_mean` field of the error names the route to use. Read one record with its fields and links with `GET /v1/graph/entity`. Read a past commit with SPARQL and `as_of_commit_seq`. [Replay answers and history](https://docs.littlebigbrain.com/guides/time-travel-audit/) shows how. The TypeScript and Python SDKs drop the matching methods. The MCP server drops the `state`, `history`, `transitions` and `why` actions of `lbb_inspect`. #### 24 September: One history per graph Graphs have no branches. Each graph keeps one line of commits. The branch fork, merge and delete routes are removed. So are the `branch` query parameter and observe (`POST /v1/memory/observe`). The SDKs and the MCP server drop their branch and observe methods and tools. Items in `GET /v1/graphs` drop the `branches` field. Graph metadata drops `parent`. The graph delete response drops `deleted_branches`. A request with `branch=main` still works, and the server ignores the value. Any other `branch` value returns `400 branch_not_supported`. #### 6 September: OPTIONAL keeps its columns under entailment A SPARQL query with an OPTIONAL block, run with `entailment=owl` or `entailment=rdfs`, returned rows missing variables bound by the required pattern whenever the optional pattern matched an entailed triple, and those rows collapsed into duplicates. The same happened, without entailment, for a subquery inside OPTIONAL. Every row now carries the required pattern's bindings, and a subquery's unprojected variables no longer take their values from the outer row. OPTIONAL under `entailment=owl` also read far more than it should on hosted graphs whose ontology declares `owl:sameAs` identities: each row of the required pattern re-read the optional pattern's blocks, so a small graph could exhaust its per-request read budget and answer `429 ingest_busy`. The optional side now folds once for the whole result, including an OPTIONAL over an entailed-only property and the `OPTIONAL { ?x ?p ?y }` shape, and a query that does exceed the read budget answers `400 scan_too_wide` instead of a retryable backlog code. ### August 2026 #### 31 August: Import typed ontology properties Ontology imports recognize `owl:DatatypeProperty` declarations as property fields. Their declared ranges map to native text, numeric, boolean, and temporal types. Object properties remain relationships between classes. TypeScript `@littlebigbrain/client` 0.13.0 and Python `littlebigbrain` 0.13.0 add typed `schemaSummary()` and `schema_summary()` methods for inspecting a graph's schema. #### 28 August: Preserve imported class hierarchies Additive ontology definitions preserve `rdfs:subClassOf` relationships. The ontology and schema APIs expose direct superclass identifiers and canonical query IRIs, including when imported vocabularies share local class names. #### 25 August: Query with OWL entailment SPARQL accepts `entailment=owl` for supported OWL reasoning, including equivalent classes and properties, inverse and transitive relationships, and bounded property chains. Reasoning applies when you query published data. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/) for the supported rules and their limits. #### 24 August: RDFS reasoning and clearer validation SPARQL accepts `entailment=rdfs` for subclass, subproperty, and domain/range reasoning, or `entailment=subclass` for the class-only subset. Asserted facts remain the default query mode. SHACL shape publishing rejects unsupported or malformed constraints with clear errors. SPARQL constraints support declared prefixes, validation messages can include the offending values, and nested violations identify the constraint that failed. TypeScript and Python 0.12.0 support publishing RDF SHACL shapes and reading ontology conformance reports. See [Query & validate](https://docs.littlebigbrain.com/guides/sparql-and-shacl/). #### 24 August: Clear errors for named-graph input RDF imports, SPARQL writes, and Graph Store requests report unsupported named-graph input. Each database graph serves one default RDF graph. Use separate database graphs and branches for isolation, and check import error counts or enable strict import validation. See [HTTP & SPARQL](https://docs.littlebigbrain.com/sdks/http-api/). #### 22 August: Reusable ontology definitions and publication waiters New graphs start with an empty ontology. Reapplying the same definition is a no-op; additive definitions evolve the ontology while incompatible changes return an error. The starter vocabulary can be added explicitly with `merge_default: true`. TypeScript and Python 0.11.1, and MCP 0.4.1, improve publication waiting with dedicated deadlines and retries. Waiters finish when the requested data is queryable. #### 21 August: SPARQL APIs and large RDF imports The public query API is SPARQL, with native protocol endpoints and TypeScript, Python, and MCP clients. TypeScript and Python 0.11.0, and MCP 0.4.0, align with that query surface. See [HTTP & SPARQL](https://docs.littlebigbrain.com/sdks/http-api/). Large RDF imports can defer publication while a chunked load is in progress, then make the completed import queryable. The bulk loader supports resumable input. See [Load RDF data](https://docs.littlebigbrain.com/guides/load-rdf/). Selective relationship queries and multi-hop SPARQL patterns also execute faster while preserving their results. #### 20 August: Reliable joins across RDF term orders Join results are independent of the order in which RDF terms entered a graph. #### 19 August: Extract years and months from temporal literals `YEAR()` accepts `xsd:gYear` and `xsd:gYearMonth`; `MONTH()` accepts `xsd:gYearMonth`. Aggregates can use these typed values directly. An aggregate whose inputs all produce type errors returns an unbound result. #### 18 August: Faster filters and clearer temporal comparisons Numeric, date, and string filters avoid scanning unrelated values. Comparisons between supported temporal values distinguish an undefined comparison from a valid comparison that matches no rows. See [FILTER comparisons and datatypes](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#filter-comparisons-and-datatypes). #### 15 August: Faster schema summaries and statistics Grouped statistics, property inventories, and schema summaries respond faster on large graphs. #### 14 August: Bounded query memory Large scans use bounded working memory, and queries report explicit errors when they exceed their resource or result limits. #### 13 August: Faster counting and entity lookups `COUNT`, `COUNT(DISTINCT ...)`, and queries anchored at a known entity perform less work on large graphs. #### 9 August: Resume interrupted index updates Interrupted index updates resume their progress. Unused index data is reclaimed according to retention rules. #### 6 August: Incremental indexing and bounded neighborhoods Indexes incorporate updates incrementally. Entity neighborhood reads apply explicit limits when exploring highly connected data. ### July 2026 #### 30 July: Durable large imports Large import jobs continue across restarts and report progress. TypeScript and Python 0.9.0 stream NDJSON request bodies to reduce client memory usage. #### 27 July: Validation as ontologies evolve Active SHACL shapes remain attached when an ontology evolves. Common minimum and maximum count constraints validate data on large graphs. #### 21 July: Selectable read consistency Reads default to the published snapshot. Applications can request strong consistency and specify the write sequence that must be visible. See [Core concepts](https://docs.littlebigbrain.com/start/concepts/#when-a-read-sees-your-write). #### 20 July: Graph fork and reload The HTTP API and TypeScript and Python 0.7.0 clients support forking a graph and replacing its contents through a reload operation. #### 19 July: Faster SPARQL on large graphs SPARQL `SELECT`, ordered queries with limits, and repeated queries execute more efficiently on large datasets. #### 18 July: Faster graph reports Graph summary, conformance, and metadata requests respond faster. Bounded exploration responses report the limits they applied. #### 15 July: Automatic retries under load Overloaded requests return `429` with `Retry-After`. TypeScript and Python 0.6.0 retry these requests within a deadline. #### 11 July: Export graph data Graphs export as NDJSON for backup and transfer between stacks. #### 10 July: Query and validate persisted graphs SPARQL queries and SHACL validation operate on graphs stored in object storage. Set-valued properties support fields such as tags and categories. #### 6 July: Validated branch merges and historical reads Branch merges validate proposed changes and report conflicts. Rejected writes identify the violated constraint so clients can correct their input. Applications can pin reads to a retained commit for reproducible results. See [History & time travel](https://docs.littlebigbrain.com/guides/time-travel-audit/) for supported selectors and retention boundaries. ### June 2026 #### 30 June: Faster bulk ingest Bulk ingest throughput improved by 3x over the previous implementation. #### 26 June: Aggregation and repeatable imports SPARQL supports grouped `COUNT`, `SUM`, and `AVG` queries over typed values. NDJSON imports can use stable external keys to update existing entities, and retractions remove previously asserted facts. #### 25 June: Query class hierarchies Subclass reasoning lets a query for a broad class include its subclasses. #### 24 June: Ontology conformance reports Conformance reports check published data against the active ontology and are available through the HTTP API and SDKs. #### 23 June: Additive ontology evolution Ontologies can add entity types and widen relationship domains or ranges while preserving existing records. #### 21 June: Typed literals Numeric and temporal literals support server-side filtering and aggregation. #### 20 June: Bounded inference rules Supported SHACL rules derive additional facts for querying. See [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/) for configuration and limits. #### 11 June: Stable reads during writes Graph snapshots keep query results consistent while new data arrives. ## Check documents against each other (experimental) URL: https://docs.littlebigbrain.com/guides/check-documents-against-each-other/ Source: docs-site/src/content/docs/guides/check-documents-against-each-other.md Description: Upload an order, a delivery note and an invoice. SHACL rules compare their values, and each difference becomes a review item with the quotes of both documents. One purchase leaves several documents. The order says 10 kg at 12.50 EUR. The delivery note says 8 kg arrived. The invoice bills 10 kg at 14.00 EUR. Each document is correct on its own. The differences show only when someone reads two of them side by side. This guide builds that check from LBB's own parts. Upload the documents. LBB reads the values, with the quote that states each. SHACL shapes state the rules between the documents. Each difference becomes a [review item](https://docs.littlebigbrain.com/guides/review-in-workflows/) for a person. The item shows the quotes of both documents. The output is a list of candidates for a person to check. The pipeline makes no payment, approval or compliance decision. It writes no fact about the purchase. A person reads the quotes and decides what each difference means. The pipeline is the `documents-check` [template](https://docs.littlebigbrain.com/guides/workflow-templates/). All names and numbers on this page are invented. ### The pipeline | Step | Part | Runs in | | --- | --- | --- | | 1 | [Files](https://docs.littlebigbrain.com/guides/documents-to-graph/#1-upload-the-files): an upload of a PDF is a `Document` | LBB | | 2 | `documents.parse` reads the PDF into pages, with every line and its box | LBB | | 3 | `documents-texts` writes the text of each parsed PDF as an instance of a class | your worker | | 4 | `values.extract` reads the text of each instance and writes its values, each with a quote | LBB | | 5 | SHACL shapes state the rules between the documents; LBB validates them after each publication | LBB | | 6 | `documents-check` files one review item per difference | your worker | Your worker runs two small workflows. Everything else is LBB-run. `documents-texts` is glue. It finds the class of a PDF in its first line. The line reads "PURCHASE ORDER", "DELIVERY NOTE" or "INVOICE". `documents-check` reads the conformance report of the shapes. ### Run it on a local stack In the LBB repository, one command starts a stack with mock models. It needs no keys. Step 2 needs `pdftotext` from Poppler. ```sh npm run dev:stack -- --worker documents-check ``` The command starts a data plane and creates the project from the template. It puts the pipeline on graph `main` and uploads three sample PDFs. Then it starts the worker. After about a minute, open the console and choose **Review**. Three items wait for you. From your own project, use any stack that has the workflow engine and models: ```sh npm create @littlebigbrain -- --template documents-check my-checks cd my-checks && npm install npm test # both workflows on the samples, with mock models LBB_GRAPH=try-documents-check npm run try # the pipeline and the sample PDFs on your stack LBB_GRAPH=try-documents-check npm start # serve the two workflows ``` `npm run setup` creates the pipeline on a graph of yours. It uploads nothing. #### What setup changes Setup publishes `shapes.ttl` as the graph's shape set. Publishing replaces the whole set, and it can change the enforcement mode. So setup reads the graph's shapes first. - A graph with shapes of its own stops the setup before anything is written. The error names the shapes. Merge `shapes.ttl` into yours and publish the union in the mode you want. - After the merge, set `publishShapes: false` in the settings. Setup then reads and writes no shape set, and it creates the rest. - A graph that holds these shapes keeps its mode. - A graph with no shapes gets `warn`. The workflows own the classes `PurchaseOrder`, `DeliveryNote` and `Invoice`. They own the properties `document_text` and `read_sha256`, and the relation `TEXT_OF`. They drop only the instances they wrote. The `rows` and `sections` templates own other names (`READ_FROM`, `text`). All three can run on one graph. ### The sample documents Three PDFs, one page each. The lines have the form `label: value`, which the mock models read. | Document | Holds | | --- | --- | | `purchase-order.pdf` | `reference: PO-1001`, `quantity: 10 kg`, `price: 12.50 EUR`, `total: 125.00 EUR` | | `delivery-note.pdf` | `reference: DN-1001`, `order: PO-1001`, `quantity: 8000 g` | | `invoice.pdf` | `reference: INV-1001`, `order: PO-1001`, `delivery: DN-1001`, `quantity: 10 kg`, `price: 14.00 EUR`, `total: 139.50 EUR` | The delivery note gives its weight in grams. The ontology declares `kg` as the unit of `quantity`. So `values.extract` writes 8, with the quote `quantity: 8000 g`. The two quantities compare in one unit without any code of yours. ### The rules A shape selects the documents of a class. It reports a result for each one that breaks the rule. A rule that compares two documents is a SHACL-SPARQL constraint (`sh:sparql`). Its `sh:select` returns two variables: - `$this`, the document that breaks the rule; - `?value`, the document it was compared with. This rule says an invoice must not bill more than the delivery note it names shows: ```turtle ex:InvoiceQuantityVsDelivery a sh:NodeShape ; sh:targetClass dclass:invoice ; sh:severity sh:Warning ; sh:sparql [ sh:message "The invoice bills more than the delivery note shows" ; sh:select """ PREFIX dclass: PREFIX dp: SELECT $this ?value WHERE { $this dp:delivery ?reference ; dp:quantity ?billed . ?value a dclass:deliverynote ; dp:reference ?reference ; dp:quantity ?delivered . FILTER (?billed > ?delivered) }""" ] . ``` A document names another by its reference. The invoice says `delivery: DN-1001`, and the delivery note has `reference: DN-1001`. The shape joins on that value. The template has four rules: | Rule | Reports | | --- | --- | | `invoice-quantity-vs-delivery` | The invoice bills more than the delivery note shows, to the gram | | `invoice-quantity-vs-order` | The invoice bills more than the order asks for, to the gram | | `invoice-price-vs-order` | The invoice price differs from the order price, compared in cents | | `total-matches-quantity-times-price` | A total differs from quantity times price by more than one cent | The last rule has a tolerance. A total that is a cent off from rounding passes. The shapes compare money in whole cents and quantities to the gram. So a floating-point error never decides. The samples break three rules and satisfy the other two on purpose. The shapes are published in `warn` mode: ```ts import { readFileSync } from "node:fs"; await lbb.schema.publish({ desired_mode: "warn", shapes: { source: readFileSync("shapes.ttl", "utf8"), format: "turtle" }, }); ``` In `warn` mode every write is accepted. The conformance report lists the differences. A rule with `sh:sparql` has no bounded set of affected records. On a records graph, `reject` mode would check the whole graph on each commit. An RDF-native graph refuses `reject` mode for such a rule. Use `warn` for rules that compare documents. See [Published SHACL conformance](https://docs.littlebigbrain.com/guides/sparql-and-shacl/#published-shacl-conformance). ### What lands in the Review inbox `documents-check` runs in three cases: - a field of a document changes; - a document is removed; - a person answers one of its items. It watches the class `BusinessDocument`, which the three document classes extend. Each run reads the conformance report. When the message is about a commit, the run waits until the report covers that commit. The wait is a durable timer, so no worker is held. The run then finds each quote in the parsed pages of its file. It files one item of kind `choice` per result. ```json { "kind": "choice", "title": "Invoice INV-1001 bills 10 kg. Delivery note DN-1001 shows 8 kg.", "subject": { "type": "Invoice", "name": "invoice.pdf" }, "question": { "options": [ { "id": "discrepancy", "label": "A real difference: follow it up" }, { "id": "misread", "label": "A value was read wrongly: correct it" }, { "id": "acceptable", "label": "Acceptable: no action" } ], "proposed": "discrepancy" }, "evidence": [ { "quote": "quantity: 10 kg", "source_id": "https://littlebigbrain.com/e/357244d5…#page=1", "region": { "page": 0, "x0": 0.121, "y0": 0.3232, "x1": 0.2413, "y1": 0.3352, "confidence": 1 } }, { "quote": "quantity: 8000 g", "source_id": "https://littlebigbrain.com/e/19a8c14d…#page=1", "region": { "page": 0, "x0": 0.121, "y0": 0.2947, "x1": 0.2526, "y1": 0.3067, "confidence": 1 } } ] } ``` Each quote names its file and page, and the box of its line. The inbox shows each quote with its source. A client of your own can open the page at the box. List the items from code: ```ts const { data } = await lbb.reviews.list({ originId: "starter:documents-check", status: "open", kind: "choice", }); for (const item of data) console.log(item.title); ``` ```sh curl "$LBB_URL/v1/reviews?graph=main&kind=choice&origin_id=starter:documents-check" \ -H "Authorization: Bearer $LBB_API_KEY" ``` ### Answer an item A person chooses one option, or **Can't tell**. A choice is a label. It writes no fact. | Answer | Meaning | | --- | --- | | A real difference | The person follows it up outside LBB. | | A value was read wrongly | The model read a document wrongly. The person corrects the value in the `value` item that `values.extract` filed, when the value was below the gate, or deletes the file and uploads the right one. | | Acceptable | The difference is known and needs no action. | A change of a value, or the deletion of a file, wakes `documents-check` again. When the report no longer lists a difference, the workflow withdraws its open item. An item that a person answered stays as answered. The key of an item holds a hash of the document IRIs and the quotes: - The same difference is the same item, however often the check runs. - A difference with other quotes is a new item. - A difference that returns reopens its withdrawn item. ### Use your own documents Change four things, all in the template: 1. **The kinds.** `KINDS` in `texts.ts` lists each class. It also lists the first line that names the class, and the properties `values.extract` fills. `PROPERTIES` in `workflow.ts` gives their types and units. 2. **The rules.** Write a shape in `shapes.ttl`. Add its entry to `checks` in `checks.ts`. The entry holds the sentences of the review item and the quotes it shows. A test fails when a shape has no entry or an entry has no shape. 3. **The context.** `defaults.context` in `workflow.ts` is one sentence about your documents, for the model. 4. **The samples.** `npm test` reads three things. They are `samples/records.json` (the text of each document), the parsed pages in `samples/pages/` and the answer key `samples/expected.json`. Replace the three together with your own. With real models, the quote of a value can differ from the quote in the key. The `records` of `samples/expected.json` have the shape of the `values-extract` answer key. They also load as an [Evals dataset](https://docs.littlebigbrain.com/guides/datasets/) of kind `values`. Use it to compare models on your documents. ### Limits - Each class has one value per property. A document with several line items needs one instance per line. Change `documents-texts` to write one instance per row. - A rule skips a document whose reference names no document of the graph. An invoice that names no delivery note is never compared with one. The same holds when the delivery note does not exist. - The first line of a PDF must equal the title of a kind, ignoring case. `documents-texts` skips "INVOICE REMINDER". - The price is read as a price per kg, as the samples say. The shapes do not compare currencies. - The text of a document is the lines of its pages, up to 20,000 characters. - A quantity is compared in the unit the ontology declares. `values.extract` converts a quantity in another unit of the same kind. It refuses a unit of another kind. - The shapes compare what the model read. A wrongly read value can start a difference or hide one. The quotes let a person see which. - A value below the gate is a `value` item, as in any `values.extract` workflow. The shapes see the value only after it is written. - A turn files at most 25 new items (`candidatesPerTurn`, at most 40). A continuation files the rest, in up to 20 more turns. An item that exists already, open or answered, takes no share of a turn. A turn reads a report window of 150 results. The state counts the results past it as skipped. - A turn that leaves a result out withdraws nothing. A value with no quote gives no item, so it leaves a result out too. Withdrawals wait for a turn that builds every item. - A turn waits 60 seconds for the report to cover its commit. If it does not, the message continues in a new turn. After 30 minutes in all, the message ends as not checked. Nothing is filed or withdrawn, and the state counts it as `unchecked`. The next change checks again. - The check workflow skips a turn that failed for good (`on_exhausted: skip`). The next message runs the whole check again. - `values.extract` does not clean up after a deleted file. Its `ValueSource` entities stay, and so do the open `value` items of the deleted file's instance. Answer or withdraw those items by hand. ## Check model quality (experimental) URL: https://docs.littlebigbrain.com/guides/check-model-quality/ Source: docs-site/src/content/docs/guides/check-model-quality.md Description: See how well the models LBB runs for a graph do their jobs, correct the judge, test other models, tune search, and calibrate thresholds and workflow gates. LBB calls models for its own work on your graph. A rerank model orders search hits. A router and an answer model answer questions from the graph. The fit maps integration streams to your classes. Model checks show how often those calls are right. A judge model checks a sample of the calls. You can agree with a check or correct it. Your correction becomes the ground truth of the call, and the judge's own verdict stays beside it. The share of reviews that agree with the judge is the judge's own score. In the console, model checks are on the **Evals** pages. LBB runs the judge with its own key. A check sends the call's input and output to the judge's provider (Anthropic for Claude Opus 5.5). They hold text from your graph: search texts and hits, questions, and samples of the connected source. LBB keeps the calls and the checks for the graph, and deletes them when you delete the graph. ### What gets checked | Job | Model | What the judge does | | --- | --- | --- | | Search rerank | Jev | grades every hit 0 to 3; the score is the nDCG@10 of the model's order | | Query routing | Jev | answers the same choice; right when it picks the same option | | Question answers | Claude Sonnet 5.5 | rates the answer against the question and the rows it read; writes the right answer or query when it is wrong | | Query rewriting | Claude Sonnet 5.5 | rates the query, and writes a corrected one when it is wrong | | Ontology fit | Jev | answers the same choice questions | | Ontology proposals | Claude Sonnet 5.5 | rates the proposed changes | | Eval labels | the eval judge | grades every labeled result 0 to 3 | | Document mapping | Jev | answers the same choice questions (when the mapping workflow runs) | The calls of your workflows are logged and not checked: the definitions, mentions and links of documents, the values from text, and the calls of a workflow's own code. The **Models** page counts them in its **Workflows** group, per workflow and job, with their calls, tokens, cost and model. A score of 0.8 or more is **right**, below 0.5 is **wrong**, and between is **partly**. Calls are sampled per graph and day, under a daily budget per environment. ### In the console Open **Evals**. The overview sums the month for the selected graph: the calls checked, the share right, the reviews, the judge's agreement with people, and the cost of the checks. Each job has a row; a row opens its checks. The month, cost and cards load independently. Cost shows `…` while loading and `n/a` when unavailable. A past month's results can appear while the current month is still loading. On **Checks**, filter by job, or show the wrong, the corrected or the not reviewed checks. A routing row reads "lookup ✓" or "lookup → history": the model's pick, then the judge's when it differs. Open a check to see the model and the judge side by side: - a routing or fit call: the model's pick and the judge's pick, each with how sure it is, and both probabilities of every option as bars; - a rerank call: the hits in the model's order, each with the model's score and the judge's grade, and how close the order is to the best one; - a question's answer: the model's answer beside the judge's, the steps the model took, its query beside the judge's, and the rows it read; - a rewrite: the model's query beside the judge's corrected query. The judge's reason follows in a few lines. **Details** holds the whole call: its prompt, its input and output, and the judge's cost. **Agree** keeps the judge's verdict. **Correct** sets the right verdict. For a rerank call you can grade each hit again, and for a choice you can pick the right option. Your grades also count in search tuning. **All calls** lists every logged call of the month. **Check** asks the judge to check one now. Checks and calls load independently of the month's summary. You can open and filter the rows while the summary is loading; if the summary fails, its error appears above the list and the rows remain usable. #### Answer in Review The checks also wait in **Review**, the inbox for every task a person does. Each check is one item: the call, the judge's verdict with its reason, and **Agree**, the two other verdicts or **Can't tell**. Your answer there is the same review as on the Checks page, and a review on the Checks page answers the item. Review holds up to 100 open checks per graph. The rest wait, and come in as you answer, the oldest first. The Checks page shows how many are open and links to them. Calibration questions and golden query labels come into Review the same way. The **Models** page shows the judge's mean score per model: "94% score" on a model's row, and the judge's agreement on its own row. The score is not the share right. For a choice, such as a routing or a mapping answer, the score is the judge's probability for the model's answer. The verdict is right when the judge's own first choice is the model's answer. ### Tune search **Search tuning** runs a session on the graph's own searches. The judge proposes search settings (the rerank, how many candidates it reads, the blend of rerank and similarity, and how many clusters a search reads), runs them, grades the hits, and scores each variant with nDCG@10, MRR@10 and recall@10. The best variant then runs against the current settings on searches it did not see. A session proposes settings only when they rank better and the 95% interval of the gain is above zero. The proposal names the settings, the gain, and the change of latency and cost per search. Nothing changes until you press **Apply**. ### Measure a workflow gate Three document workflows send their unsure answers to the inbox: **Link documents**, **Review facts** and **Values from text**. An answer at or above the workflow's gate (0.8 by default) is written at once. An answer below it becomes a review item. Each time you accept or dismiss one, you tell LBB whether the model's answer was right at that confidence. **Evals → Workflow gates** counts those decisions per confidence band below each gate (0 to 0.5, then 0.1 wide): how many, how many were right, the precision and its 95% interval. Then it tries the gates below the current one, 0.05 apart, from the highest down. A gate passes when the decisions from it up to the current gate number at least 30, are right at least as often as the target precision (95% by default), and have their interval start at most 10 points below the target. The decisions just above it (the next 0.1) must also hold one decision, and from 10 decisions be right at least the target less 10 points of the time, so a bad region does not hide under good decisions above it. The card proposes the lowest gate that passes and shows, for each gate, what holds it. The inbox only sees answers below the gate, so the page cannot measure the bands above it, and a proposal only lowers a gate, never below 0.5. **Measure above the gate** on a card starts a calibration session of that gate, which checks the answers above the gate too (see below). For **Link documents**, an item where the model chose no entry counts as right when you dismiss it: nothing is written, as the gate would do. Nothing changes until you press **Apply** and confirm. You can pick the proposed gate or a higher one. The workflow then reads its instances again under the new gate, and its row on the Workflows page shows "gate 0.65 · 41 decisions · measured 2026-10-12". An API key cannot apply a gate. ### Test another model A model trial tests another model on the jobs above. It answers the calls the judge already checked again with that model, for example Claude Haiku 5.5 for question answers or query routing. Then it scores both models against the same ground truth: your corrections where you made them, the judge's verdicts otherwise. - A routing or fit call scores 1 when the model picks the right option. - A rerank or label call scores the nDCG@10 of the model's order under the ground-truth grades. - A question's answer is graded by the judge, with the known right answer as its reference. The new model's queries read the same commit as the first answer, when the graph still has it. In the console, open **Models**, then **Model choice**. Each use of a model (question answers, query routing, search rerank, …) has a card with its current model. Pick another model (and its effort) in the card's model menu to test it. The trial runs by itself: it answers the checked calls, newest first, and adds each new check of that use for 14 days or until it has 40 calls. The card's chart shows every model's score against its cost per call, with the 95% interval of each tested model. Pick **Time** above the chart to see the time per call instead. Open a trial to see each call with both answers and the ground truth side by side. A model **meets the bar** when at least 20 calls were compared, it is at most 5 points worse at the low end of the 95% interval, it fails at most 5 points more often, and it is cheaper or better. A trial changes no model by itself. One trial spends at most $3.00 of model and judge cost. When a model meets the bar, **Switch** moves that use to the model on this graph from the next call on. Question answers switch to a Claude model and its effort, query routing to a Jev model; the other uses are tested only. **Revert** goes back to LBB's model in one step. A trial always compares a model with the model in use now. After a switch, most checked calls come from the model before. A new trial has the model in use answer those calls again, so both models answer the same calls. Trials that compared with the model before end, and their models show **Test again** instead of **Switch**. ### Choose the model of a workflow step The AI steps of workflows run two models. The **Extract** model reads values from text and answers the JSON prompts of a workflow's code (the `reason` activity). The **Decide** model answers choice questions: whether the text supports a value, and the `decide` activity. LBB runs Claude Sonnet 5.5 for Extract and Jev for Decide. **Embed** keeps its model, since vectors of two models do not compare. You can choose another model for the whole environment, or for one workflow. A workflow takes its own choice first, then the environment's, then LBB's model. Each model call says in the call log which choice it followed (`choice`). These calls have no checked calls, so a trial cannot test them. Test the model on the workflow's own inputs instead: 1. Open the workflow, then **Spend**. **Models by step** lists each step with its model, where the model comes from, and this month's calls and cost. 2. **Change** a step. Pick a model and its effort. The dialog says how the price per token moves, for example "about 20 times cheaper per token". 3. **Test first** runs the workflow on 6 inputs with that model. It writes nothing. Compare its values with an earlier test on the **Test** tab. 4. **Set for this workflow** makes the next step run the model. **Use the environment's** goes back in one step. For the environment, open **Models**, then **Model choice**, and pick a step under **Workflow steps**. Its card lists the workflows that run a model of their own. A change takes effect from the next step of a workflow. Other servers take it within 30 seconds. A cheaper model lowers the cost per call from its first call, and the budgets count each call at its own price. ### Calibrate thresholds and workflow gates Jev answers each routing question and each fit question with a probability. A threshold sets which answers LBB takes at once. Ask takes the router's route when its confidence is at least the threshold; below it, the answer loop reads only the probabilities. Both jobs start at 0.70, a value nobody measured on your data. The document workflows use a gate the same way. **Link documents** writes a link, **Values from text** a value and **Review facts** a mapped fact when its confidence is at least the workflow's gate (0.80 by default). Below the gate the decision waits in the inbox for a person. Nobody sees the decisions above the gate. A calibration session measures, on the graph's own calls, how often the model is right at each confidence level. A judge checks a sample of the calls, and you check a part of the judge's verdicts. The session then proposes a threshold for a target precision. For a workflow, the sample holds decisions above the gate too, so the session tells you how often the decisions written without a person are right. Open **Evals**, then **Calibration**. **Query routing** and **Ontology fit** each have a card with the threshold that applies now, whether people validated it, and the last session. Below them, each workflow with a gate has a card with its gate and the last session that measured it. **New calibration** (or **Calibrate** on a card) asks for: - the job, or the workflow when you start from its card; - the target precision: 99% for the fit, 95% for routing, which writes no data. A workflow's gate starts at its own target, 95% unless the workflow sets another. The inbox measurement of the gate uses the same target. You can change it for one session; - the most questions to answer (40); - a budget for the judge in US dollars (10). It does not count against the daily check budget; - the window of calls to read, in days (30). While the session runs, its page shows the decisions it found, sampled and checked, and the judge's cost against the budget. Then **Review** shows one decision at a time: what the model read, what it chose and how sure it was, and the judge's check with its reason. A workflow's decision that was written without a person says so. When the two picks are the same, the card asks whether that pick is right: **Yes**, **No** or **Can't tell** (keys Y, N and C). When they differ, it asks which is right: the model's pick (key 1), the judge's pick (key 2), **Something else** (key N) or **Can't tell** (key C). After **No** or **Something else** you can name the right option. Disputed decisions come first, and you can change an answer later. The review asks for the answers the session still needs before it stops at its most questions. The same questions wait in **Review**, one item per question, and **Answer** on the session page opens them there; an answer in either place is the session's answer. The report starts with the model: how often it is right over all its decisions, and per confidence band. For a workflow it says how often the decisions above the gate are right, and how often the ones below it are. Then it says how far to trust these numbers. The judge checked the sample, and your answers check the judge: for example, people checked 16 of its verdicts and agreed with 15 (94%, with a 95% range of 72% to 99%). A session is **validated** when the range is at most 10 points on each side (or the most questions have answers) and starts at 80% or higher. The table reads the thresholds from 0.95 down. At each one it shows the estimated accuracy and where its 95% interval starts, the share of decisions at or above it, the known decisions there, and the next 0.1 above it. A threshold meets the rule when at least 10 known decisions lie at or above it, they reach the target, and their interval starts at most 10 points below the target. A threshold below the target stops the walk. So does a next 0.1 that holds decisions but no known one, or that is more than 10 points below the target. The proposal is the lowest threshold that meets the rule. The workflow gates from the inbox use the same rule. **Apply** writes a threshold: the proposal, a row of the table, or a value from 0.50 to 0.99. The page asks first and says what the threshold does. A session that is not validated can still be applied, with a warning. The router uses a new threshold within a minute. The integrations do not read the fit threshold yet. For a workflow, **Apply** sets its gate, and the workflow reads its instances again with it. The workflow keeps where its gate came from, and its row on **Workflows** says "from a session". The **Models** pages for question answering and the fit show the same threshold, with a **Calibrate** link. ### With the SDKs The TypeScript and Python clients wrap trials and switches under `models`. `wait` reads the trial until it compared the checked calls. ```ts const { trials: [trial] } = await lbb.models.trials.create({ candidate: { provider: "anthropic", model: "claude-haiku-5-5", effort: "low" }, jobs: ["ask"], }); const result = await lbb.models.trials.wait(trial.id); if (result.report.qualifies) await lbb.models.switches.create({ trial: result.id }); ``` ```python started = lbb.models.trials.create( candidate={"provider": "anthropic", "model": "claude-haiku-5-5", "effort": "low"}, jobs=["ask"], ) trial = lbb.models.trials.wait(started.trials[0].id) if trial.report.qualifies: lbb.models.switches.create(trial=trial.id) ``` The clients choose the model of a workflow step with `models.choices`, and test a model first with `models` on a workflow test: ```ts await lbb.workflows.tests.create({ name: "products", sample: 6, models: { extract: { provider: "anthropic", model: "claude-haiku-5-5", effort: "low" } }, }); await lbb.models.choices.put({ use: "extract", model: { provider: "anthropic", model: "claude-haiku-5-5", effort: "low" }, workflow: "products", }); ``` ```python lbb.models.choices.put( use="extract", model={"provider": "anthropic", "model": "claude-haiku-5-5", "effort": "low"}, workflow="products", ) lbb.models.choices.delete(use="extract", workflow="products") ``` The MCP server has the same: `lbb_model_choice` reads (`choices` lists the model choices), and `lbb_model_choice_manage` starts, stops, switches and reverts, and sets or removes a choice (`put_choice`, `delete_choice`). ### Over HTTP ```sh # The month's checks of a graph by job, and the judge's agreement curl "$LBB_URL/v1/models/checks/summary?graph=crm" \ -H "Authorization: Bearer $LBB_API_KEY" # Test Claude Haiku 5.5 on question answers and query routing curl -X POST "$LBB_URL/v1/models/trials?graph=crm" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"candidate": {"provider": "anthropic", "model": "claude-haiku-5-5", "effort": "low"}, "jobs": ["ask", "route"]}' # Correct a check: the right verdict and a note curl -X POST "$LBB_URL/v1/models/checks/review?graph=crm&id=$CALL_ID" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"agree": false, "verdict": "wrong", "note": "Contacts are companies."}' ``` | Route | What it does | | --- | --- | | `GET /v1/models/checks/summary` | the month per job and model, the judge's agreement, today's budget | | `GET /v1/models/checks` | the month's checks, newest first; `job`, `verdict`, `reviewed` filter | | `POST /v1/models/checks/review` | `{agree: true}`, or a correction with `verdict`, `reference` and `note` | | `GET /v1/models/checks/export` | the month's checks as JSON lines: the call, the judge and the review | | `GET /v1/models/calls` | the call log, newest first | | `GET /v1/models/calls/get` | one call with its input, output and check | | `POST /v1/models/calls/check` | check one call now | | `POST /v1/search/tuning` | start a tuning session | | `GET /v1/search/tuning` | the sessions, newest first | | `POST /v1/search/tuning/apply` | apply a session's proposal | | `GET /v1/workflows/triggered/calibration` | the workflow gates measured from inbox decisions: bands, labels and a proposal | | `POST /v1/workflows/triggered/calibration/apply` | apply a gate's proposal (a console session only): `{starter, name, gate?}` | | `GET /v1/models/trials/options` | per job: the current model, its checked calls, the models a trial can test | | `POST /v1/models/trials` | test a model: `{candidate: {provider, model, effort?}, jobs?}` | | `GET /v1/models/trials` | the trials, newest first, with their reports | | `GET /v1/models/trials/get` | one trial with its compared calls | | `POST /v1/models/trials/stop` | stop a trial | | `POST /v1/models/switches` | switch a task to a trial's model: `{trial}` | | `POST /v1/models/switches/revert` | back to LBB's model: `?job=ask` | | `GET /v1/models/choices` | the model of each workflow use: the environment's rows, and with `workflow` the rows in force for one workflow | | `PUT /v1/models/choices` | choose a model: `{use, model: {provider, model, effort?}, workflow?}` | | `DELETE /v1/models/choices` | remove a choice: `?use=extract&workflow=products`; answers `{id, object, deleted: true}` | | `POST /v1/models/calibration` | start a calibration session: `{"job": "route"}`, or a workflow's gate: `{"job": "resolve", "workflow": "documents-link"}` (`infer` for Values from text, `map` for Review facts) | | `GET /v1/models/calibration` | the calibration sessions, newest first | | `GET /v1/models/calibration/get` | one session with its questions and report | | `POST /v1/models/calibration/answer` | answer a question: `yes`, `no` (with an optional `right`) or `cant_tell` | | `POST /v1/models/calibration/apply` | apply the proposal, or a `threshold` from 0.50 to 0.99; for a workflow, its new gate | | `GET /v1/models/thresholds` | the applied thresholds of `route` and `fit` | A rerank correction takes `reference: {"grades": {"": 0..3}}`, and a choice correction `reference: {"picks": {"": "