OriginChain docs
schema · natural language

Natural language.

Natural language lets you send a question instead of a query. The engine compiles it against the schemas you have registered, runs the result, and hands back rows — in the same shape any other read would.

It earns its place in two situations: exploration, when you don't yet know the shape of the data well enough to write the query, and end-user surfaces, where the person asking will never write a query at all. For anything on a hot path or in a code path you will maintain, write the SQL yourself — see when to use which.

Every operation below is shown in cURL, Python, TypeScript and Go. SDK coverage varies more here than anywhere else on the docs — each tab says what it can and cannot do.

1

Before you start.

Every example uses the shop.orders table from the quickstart. NL adds no schema syntax of its own — what it needs is simply that your tables are registered, because the column declarations are the entire context the compiler gets.

schemas/orders.toml
# NL has no schema knobs of its own. What it needs is that the tables you
# want it to reason about are REGISTERED - the compiler is given the column
# names and types of your schemas as its entire context, so a column that
# is not declared is a column it cannot use.

namespace   = "shop"
table       = "orders"
primary_key = ["id"]

[[columns]]
name = "id"
ty   = "str"
required = true

[[columns]]
name = "customer"
ty   = "str"

[[columns]]
name = "amount_cents"
ty   = "i64"

[[columns]]
name = "status"
ty   = "str"

[[columns]]
name = "notes"
ty   = "str"

[[columns]]
name = "placed_ms"
ty   = "u64"

# Not required by NL, but the compiled plan will use it like any other
# query would - so an indexed filter column makes the answer faster.
[[indexes]]
name    = "by_status"
columns = ["status"]
column names are your prompt

The compiler sees column names and types — nothing else. No sample rows, no comments, no descriptions. That makes naming load-bearing: amount_cents tells it far more than amt, and placed_ms tells it more than ts. If a question keeps compiling wrong, look at your column names before you look at your phrasing.

2

Ask a question.

One required field: nl. Note the name — it is not question or query.

POST /v1/tenants/:tenant/ask
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/ask" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "nl":      "the 5 largest paid orders",
    "schemas": ["shop.orders"]
  }'
response
{
  "rows": [
    { "id": "01JTRX9KQ3YH8K2WMX0F5JZAB7", "customer": "01JTRX1H4Q9P0N2WMX0F5JZ001",
      "amount_cents": 48200, "status": "paid", "placed_ms": 1714478049000 },
    { "id": "01JTRX9KQ3YH8K2WMX0F5JZAC1", "customer": "01JTRX1H4Q9P0N2WMX0F5JZ004",
      "amount_cents": 31150, "status": "paid", "placed_ms": 1714477012000 }
  ],
  "cache": "miss"
}

rows is the answer. cache tells you whether the question was answered from a previously compiled plan ("hit") or compiled fresh ("miss") — a cache hit skips the model entirely and is dramatically faster.

what the response does not contain

There is no confidence score, no token usage, no model name, and no generated SQL string. You get rows, a cache flag, and — on request — the plan. If you were hoping to gate on a confidence value before showing results to a user, that signal does not exist; validate by inspecting the plan instead.

the cache is keyed on your question and your schema

Questions are normalised before caching — whitespace collapsed, case folded outside quoted strings, trailing punctuation stripped — so "Show me 5 paid orders" and "show me 5 paid orders." share a cache entry. The key also folds in a hash of every registered manifest, so changing a schema automatically invalidates every cached plan. You never have to clear it by hand after a migration.

3

Seeing the compiled plan.

show_plan: true returns the compiled plan alongside the rows. This is the single most useful thing on this page — it is how you check that the question was understood the way you meant it.

"show_plan": true
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/ask" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "nl":        "the 5 largest paid orders",
    "schemas":   ["shop.orders"],
    "show_plan": true
  }'
response
{
  "rows": [ /* … */ ],
  "cache": "miss",
  "plan": {
    "op": "limit",
    "n":  5,
    "child": {
      "op": "sort",
      "keys": [ { "path": "amount_cents", "order": "desc" } ],
      "child": {
        "op": "filter",
        "predicate": { "op": "eq", "path": "status", "value": "paid" },
        "child": { "op": "scan", "schema": "shop.orders" }
      }
    }
  }
}

Read it inside-out: scan shop.orders, keep rows where status = "paid", sort by amount_cents descending, take 5. That is exactly the question, and you can see it is exactly the question — which is the whole point.

  • The plan returned is the post-optimisation one, so it may differ slightly from what the compiler first emitted.
  • Adding ?explain to the URL also turns the plan on, and additionally returns an explain field with a cost-annotated tree. Careful: any value enables it — ?explain=false switches it on just as surely as ?explain=true.
  • TypeScript is the only SDK that exposes show_plan. Python's ask() has no such parameter; Go's Ask() takes only the question, so its AskResponse.Plan field can never be populated.
4

What it compiles to.

Not to SQL. A question compiles to the same internal query plan that a SQL statement compiles to — the structure you saw above. The executor cannot tell which surface produced it, which is why NL inherits the same optimiser and the same execution guarantees as everything else.

There is no SQL string anywhere in the pipeline, so there is nothing to show you if you were looking for "the SQL it wrote". The plan is the compiled query.

Two compilers, in order.

This is worth knowing because it explains a lot of the behaviour:

  1. A deterministic rule compiler runs first. It understands a compact grammar — roughly [top N] <table> [where <condition>] [by <sort>] [limit N] — with the operators =, ==, !=, > and <. If your question fits, you get a plan with no model call at all — fast, free and perfectly repeatable.
  2. Only if the rule compiler cannot parse the question does it fall through to the managed language model, which emits a plan in the same JSON shape.

Phrasing a question in the rule grammar's shape — "top 5 shop.orders where status = paid by amount_cents desc" — is therefore a real performance technique, not a stylistic one. Note the rule compiler has no >= or <=, cannot mix and with or in one condition, and does no aggregation — those questions go to the model.

What the plan grammar can express.

Supported Not available through NL
Table scans and column projections
Index lookups
Filters — eq, ne, gt, lt, in, and, or
Sorting and limits
Grouping with count, sum, avg, min, max
Single-hop relation walks
Catalog questions ("what tables are there")
Joins — the grammar has no join shape
Any other aggregate (median, percentile, stddev, count-distinct)
DISTINCT, window functions, CTEs, subqueries
Set operations (UNION and friends)
Graph algorithms and variable-length paths
Any write — insert, update, delete
Any schema change
ask is read-only, structurally

A question cannot modify data, whatever it says. The endpoint executes through the read path, which holds an immutable handle on storage and refuses write operations outright — "delete all cancelled orders" returns a 400, not a deletion. There is no schema-mutation shape in the grammar at all, so DDL is not expressible in the first place. NL also does not participate in transactions.

joins are the big one

The engine supports joins perfectly well from SQL — but the NL plan grammar has no join shape, so a question spanning two tables cannot compile into one. Ask "which customers spent the most" across orders and customers and you will get an error or an answer drawn from one table only. Single-hop relation walks are the closest available thing. For genuine multi-table questions, write the SQL.

5

Grounding it in your schema.

Omit schemas and every table registered on the instance becomes context. Pass it and only the listed tables are visible. On an instance with more than a handful of tables, scoping is the highest-leverage thing you can do for both accuracy and latency.

scoping the compiler's context
# Unscoped - every schema registered on the instance becomes context.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/ask" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "nl": "how many orders are unpaid" }'

# Scoped - only these tables are visible to the compiler.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/ask" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "nl":      "how many orders are unpaid",
    "schemas": ["shop.orders"]
  }'
  • Ids must be fully qualified — shop.orders, not orders. An id that is not registered is a 400.
  • Only names and types are sent — never any of your row data. No sample rows are included in the compiler's context.
  • There is no size cap on that context, so an instance with very many tables produces a very large prompt. Scope it.
  • Both Python and TypeScript expose schemas. Go does not — its Ask() always runs unscoped.
table names are validated, column names are not

Every table a plan references is checked against your catalog, and a plan naming an unknown table is rejected before it runs. Columns get no such check. A plan that filters on a column you do not have will pass validation and be executed — typically returning zero rows rather than an error. An empty answer to a question you expected to match something is the signature of this, and show_plan is how you confirm it.

6

Failure modes.

Compilation problems are all 400s with a compile: prefix. The useful thing is that they are specific — the engine distinguishes "I don't understand this" from "this is ambiguous" from "that table doesn't exist".

An ambiguous question.

If a bare table name matches two namespaces, you get a hard error rather than a guess — and notably, the question is not passed to the model to be resolved. Ambiguity is treated as your intent to name a table, stated imprecisely.

{
  "error": "compile: ambiguous schema \"orders\" — qualify as <namespace>.<table>"
}

A question it cannot compile.

An unsupported aggregation — "the median order value" — or a question the model cannot turn into a valid plan ends here. The model is given up to three attempts, each one shown the previous rejection so it can self-correct; if all three fail you get:

{
  "error": "compile: unparseable natural-language query: …"
}
Situation Status
Empty or whitespace-only question400
Ambiguous or unknown table400
Unsupported aggregation or shape400
A question that implies a write400
No schemas registered on the instance400
Answer exceeds the result budget413
Rate or concurrency cap hit429
Monthly credit exhausted402
the quiet failure is the dangerous one

Every case above is loud. The failure to actually watch for is the plausible wrong answer: a question that compiles cleanly into a plan that is not quite what you meant — a dropped qualifier, a filter on the wrong column, a limit that was silently ignored. It returns 200 and looks fine. This is inherent to the approach, not a bug, and it is exactly why show_plan exists. Check the plan before you trust a number.

a compile can take a while

A cache miss means a model round trip, with a 60-second ceiling per attempt and up to three attempts — so a pathological question can sit for a couple of minutes before returning a 400. Set a client timeout you are comfortable with, and don't put an uncached NL call in a request path with a tight budget.

7

Availability and caps.

not available on a free database

Natural language is a paid-database feature. On a free database the console does not show the NL option at all — the query workbench's language switcher reads SQL / Cypher / Search — and the endpoint is gated. Select a paid instance and the option reappears.

On a paid instance, three independent limits apply. Because each compile is genuinely expensive, /ask is metered separately from ordinary database calls — running out of NL credit never affects the rest of your database.

Limit Window On breach
Ask credits Monthly 402
Concurrent calls in flight Instantaneous 429
Request rate Per second 429

Both the concurrency and per-second allowances scale with the configuration you are running — the entry configuration allows 5 questions in flight at once, the standard configuration 15, and the advanced configuration 50. There is no per-day cap; the windows are monthly, instantaneous and per-second, and nothing else.

402 — credits exhausted
{
  "error":      "quota_exhausted",
  "resource":   "ask",
  "used":       5000,
  "limit":      5000,
  "message":    "monthly /ask quota exhausted (5000 / 5000). top up credits at
                 https://app.originchain.ai/billing.",
  "top_up_url": "https://app.originchain.ai/billing"
}
429 — too many in flight
{
  "error":         "concurrent_cap_exceeded",
  "resource":      "ask",
  "in_flight":     5,
  "cap":           5,
  "configuration": "entry",
  "msg":           "Tenant has 5 concurrent ask call(s) in flight; the entry
                    configuration allows 5. Wait for in-flight calls to
                    complete or upgrade your configuration."
}

The concurrency rejection carries Retry-After: 1 and is genuinely transient — retry it. The 402 carries no Retry-After, because retrying will not help until credits are topped up. Live in-flight usage is visible on GET /v1/tenants/:tenant/usage.

There is no maximum question length beyond the 8 MB request-body limit — but a long question is not a better one, and everything past the part that names tables and conditions is noise the compiler has to work through.

8

NL vs writing the query yourself.

Reach for NL when… Write SQL when…
You are exploring unfamiliar data
A non-technical user is asking the question
The question is one-off and the cost of being slightly wrong is low
You want a starting point to refine into real SQL
The query runs more than once
It sits on a latency-sensitive path
Correctness is not negotiable
It needs a join, a set operation, or an aggregate outside the five basics
It writes anything at all

The most productive pattern is to treat NL as a drafting tool: ask the question with show_plan: true, read the plan to learn what the engine thinks your data means, then write the equivalent SQL and ship that. You get the exploration speed without putting a compile step in your production path.

For a user-facing "ask your data" feature, cache aggressively at your own layer too. Identical questions already hit the engine's plan cache, but the questions real users type are rarely byte-identical.

Related.