Queries
A query is a typed, read-only read over one dataset — a declarative spec the compiler translates to a parameterized SELECT, declared in the manifest and called on demand.
A query is the read mirror of an action. Where an action is a typed write into a
source dataset, a query is a typed, read-only read over one dataset, declared in the manifest
and called on demand. You don't write SQL: a query is a declarative spec (a dataset, optional
filters, a projection, an order) that the compiler translates to a parameterized SELECT. You
declare a query; an agent runs it over MCP with oi.app().runQuery.
Why declarative
A query is a spec, not raw SQL in a JSON string. The compiler reads the spec and emits the SQL. That buys five things:
- No casting footgun. The translator emits type-aware casts itself. You never write a
TRY_CASTor anILIKE; you name a field and an op and it compiles to the right comparison. - Fields are validated. Every
fieldyou reference is checked against the dataset's live columns at build time, like an island binding. A field that doesn't exist is a named error, not an empty result. - Injection-safe. Every param and every literal
valueis bound, never interpolated. Only verified column identifiers are quoted into the SQL. - Agent-authorable. The spec is plain JSON, so an agent writes a query through the normal
oi.app().patchManifest→applyEditloop, the same loop it uses for islands (see Authoring queries). - No joins by design. A query reads one dataset. When a read needs joins or other heavy
shaping, that lives in a
sqltransform the query'sdatasetpoints at.
What a query reads
One dataset: a raw source dataset or a sql transform, named by dataset. Because a transform
is itself a dataset, a query over a transform reads already-shaped, already-joined columns. Think
of a query as a saved, named, typed version of the ad-hoc oi.app().runSql read, scoped to a
single dataset.
A query is the dual of a transform, not a replacement for one. A transform is a file-backed view bound to islands: it renders, takes no parameters, and shapes data once. A query renders nothing, takes parameters, and returns rows when called. A query often selects from a transform: let the transform do the joins and aggregation, and let the query parameterize the read.
Declaring one
A query names its dataset and, optionally, the params it accepts and how to where / select
/ orderBy / limit the rows:
"queries": {
"get_daily_macros": {
"description": "Macros + goals for one day; omit date for the latest.",
"dataset": "macros_daily",
"params": { "date": { "type": "date", "required": false } },
"where": [{ "field": "date", "op": "eq", "param": "date" }],
"orderBy": [{ "field": "date", "dir": "desc" }],
"limit": 1
}
}That reads one row from macros_daily. With a date, it's that day; with no date, the filter
drops and the orderBy + limit return the latest row (see Filters).
Parameters
params maps each parameter name to its declaration. A where clause references a param by name
in its param field; the value is bound when the query runs.
| Field | Type | Description |
|---|---|---|
type | "string" | "number" | "boolean" | "date" | The parameter's type. Defaults to string. |
required | boolean | Whether the caller must supply it. Defaults to true. false makes it optional. |
enum | array of string | Restrict a string parameter to a fixed set of values. |
min / max | number | Numeric bounds. |
default | string | number | boolean | A value used when the caller omits it. Implies optional. |
description | string | A note that rides along with the param, so the agent knows what it means. |
Filters
where is a list of filters, ANDed together. Each names a field, an op, and exactly one of
param (bind a declared param) or value (a literal):
| Op | Match |
|---|---|
eq / ne | Equal / not equal. |
lt / lte / gt / gte | Ordered comparison. |
contains | Case-insensitive substring. |
sameDay | A timestamp field falls on a given date. |
in | The field is one of a literal value array. |
A filter bound to an optional param the caller omits is dropped; the query runs as if the
filter weren't there. That's the latest-row pattern above: omit date and the day filter
disappears, leaving order by date desc limit 1.
Projection
select is a list of columns to return; omit it for all of them. An entry is either a column name
or { field, fn?, as? }, where fn is sum, avg, count, min, or max. Aggregates pair
with groupBy, a list of columns to group by:
"queries": {
"spend_by_category": {
"dataset": "transactions",
"select": [{ "field": "category" }, { "field": "amount", "fn": "sum", "as": "total" }],
"groupBy": ["category"],
"orderBy": [{ "field": "total", "dir": "desc" }],
"limit": 10
}
}orderBy is a list of { field, dir? } sort keys (dir is asc or desc, default asc), and
limit caps the rows.
Search
search turns a query into a relevance-ranked full-text search over text columns. It compiles to
DuckDB's full-text index and ranks rows by BM25, so a multi-word term matches rows that share any
token, best match first. That's the difference from a contains filter: contains is a
whole-phrase, case-insensitive substring match and returns rows unranked; search tokenizes both
the columns and the term and orders by likeliness. Searching greek yogurt returns Greek Yogurt
above Olympus Yogurt — which only shares the token yogurt — and excludes Banana.
"queries": {
"search_ingredients": {
"description": "Find ingredients by name or brand, best match first.",
"dataset": "ingredients",
"params": { "q": { "type": "string" } },
"search": { "fields": ["name", "brand"], "param": "q", "scoreField": "score" },
"limit": 20
}
}A search clause names:
| Field | Type | Description |
|---|---|---|
fields | array of string | The text columns to index and search across. Each must be a real column of the dataset, checked like any other field. |
param | string | The name of a declared string param that holds the search term. |
stemmer | "porter" | "none" | How tokens are folded. porter (default) collapses plurals and tenses; none matches exact tokens. |
stopwords | "english" | "none" | Whether to drop common words from the index and the term. english (default) drops them; none keeps them. |
scoreField | string | Optional. Names a column to expose the BM25 relevance score under; omit to keep the score internal (ordering only). |
search composes with the rest of the spec. A where clause further filters the matched rows; an
omitted orderBy defaults to relevance (highest BM25 first), and an explicit orderBy overrides
that order; limit caps the rows as always.
search reads a source dataset, not a sql transform. (A full-text index can't track changes to
the files a transform reads, so it would silently go stale.) Because the index needs a real table,
declaring search materializes the dataset into an indexed table at build time — the manifest gates
that, and it's invisible to the query.
Running a query
Queries belong to the agent edit loop, not to a CLI command. An agent:
- Calls
oi.app().listQueries()to get each declared query: itsname,description, itsparamsas a JSON Schema, and the resultcolumns. That's its grounding for what to pass and what comes back. - Calls
oi.app().runQuery(name, params?, { limit? }), which validates the params and runs the compiledSELECT. On success it returns{ ok: true, rowCount, columns, rows }. A bad param rejects the whole call with{ ok: false, errors }(all-or-nothing); an unknown name or a query error returns{ ok: false, error }.limitis 1–500 and the result is row-capped regardless.
Authoring queries
Because a query is declarative JSON, an agent writes one end-to-end through the same
oi.app().patchManifest → applyEdit loop it uses for islands and actions. No new tool, no file
write. It reads the data, drafts the queries block, stages the change, reviews the diff, and
applies it. The MCP write path only ever writes manifest.json, so a JSON spec is what lets
an agent create a read tool, not just run one.
patchManifest / replaceManifest and validate enforce the same checks: the dataset must
exist, and every field in where, select, groupBy, and orderBy must be a real column on it.
A field that doesn't exist is a named error, caught before the manifest lands: the same fail-loud
binding check an island gets.
Note
A query is read-only by construction: the compiler emits a single SELECT, every param and
literal is bound rather than interpolated, only verified column identifiers are quoted, and the
result is row-capped. Data that tries to talk an agent into a harmful read still can't escape
validation or the row cap.
Where to go next
- SQL Transforms: the file-backed views a query selects from.
- Actions: the typed write a query mirrors.
- MCP Server:
oi.app().listQueries()andrunQueryinside the agent loop.