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.
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.
# 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"]
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.
Ask a question.
One required field: nl. Note the name — it is not question or query.
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"]
}'result = db.ask("the 5 largest paid orders", schemas=["shop.orders"])
for row in result["rows"]:
print(row["id"], row["amount_cents"])
print(result["cache"]) # "hit" or "miss"const result = await db.ask("the 5 largest paid orders", {
schemas: ["shop.orders"],
});
for (const row of result.rows) console.log(row);
console.log(result.cache); // "hit" or "miss"// Go's Ask takes only the question - it sends no schemas allowlist
// and cannot request the plan. Call the endpoint directly if you need those.
resp, err := db.Ask(ctx, "the 5 largest paid orders")
if err != nil {
return err
}
fmt.Println(resp.Cache, len(resp.Rows)) {
"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.
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.
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.
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.
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
}'# The Python SDK's ask() does not expose show_plan - call the
# endpoint directly when you need the compiled plan back.
import httpx
r = httpx.post(
f"https://{OC_HOST}/v1/tenants/{OC_TENANT}/ask",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={
"nl": "the 5 largest paid orders",
"schemas": ["shop.orders"],
"show_plan": True,
},
)
print(r.json()["plan"])// TypeScript is the only SDK that exposes show_plan.
const result = await db.ask("the 5 largest paid orders", {
schemas: ["shop.orders"],
show_plan: true,
});
console.log(JSON.stringify(result.plan, null, 2));// The Go SDK cannot send show_plan - its AskResponse.Plan field
// therefore stays empty. Use net/http for the plan.
body, _ := json.Marshal(map[string]any{
"nl": "the 5 largest paid orders",
"schemas": []string{"shop.orders"},
"show_plan": true,
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/ask",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) {
"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
?explainto the URL also turns the plan on, and additionally returns anexplainfield with a cost-annotated tree. Careful: any value enables it —?explain=falseswitches it on just as surely as?explain=true. - TypeScript is the only SDK that exposes
show_plan. Python'sask()has no such parameter; Go'sAsk()takes only the question, so itsAskResponse.Planfield can never be populated.
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:
- 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. - 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, orSorting and limits Grouping with count, sum, avg, min, maxSingle-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, subqueriesSet operations ( UNION and friends)Graph algorithms and variable-length paths Any write — insert, update, delete Any schema change |
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.
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.
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.
# 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"]
}'# Unscoped - every registered schema becomes context.
db.ask("how many orders are unpaid")
# Scoped - only these tables are visible to the compiler.
db.ask("how many orders are unpaid", schemas=["shop.orders"])// Unscoped - every registered schema becomes context.
await db.ask("how many orders are unpaid");
// Scoped - only these tables are visible to the compiler.
await db.ask("how many orders are unpaid", {
schemas: ["shop.orders"],
});// Go's Ask() sends no schemas allowlist, so it always runs unscoped.
// Call the endpoint directly to scope it.
body, _ := json.Marshal(map[string]any{
"nl": "how many orders are unpaid",
"schemas": []string{"shop.orders"},
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/ask",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) - Ids must be fully qualified —
shop.orders, notorders. An id that is not registered is a400. - 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 — itsAsk()always runs unscoped.
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.
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 question | 400 |
| Ambiguous or unknown table | 400 |
| Unsupported aggregation or shape | 400 |
| A question that implies a write | 400 |
| No schemas registered on the instance | 400 |
| Answer exceeds the result budget | 413 |
| Rate or concurrency cap hit | 429 |
| Monthly credit exhausted | 402 |
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 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.
Availability and caps.
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.
{
"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"
} {
"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.
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.
- The ask endpoint — the full HTTP reference.
- Natural-language examples — one focused page per scenario.
- SQL — what to write once the question stops changing.
- Asking from the dashboard — NL in the query workbench.
- Full schema reference — every block the TOML grammar accepts.