Query Syntax
Everything that reads an app's store uses one filter grammar: query() and subscribe() on a page, GET ./api/query underneath them, graph_query in an agent's tools, a query in the app's own commands, and env.STORE.fetch from a worker. This page describes that grammar with examples: what selects rows, which components a returned row carries, and the few things this store refuses.
For a one-time read of values held by connected peers, such as a live game position, use GET ./api/query?live=1&q=<encoded filter> or the same path through env.STORE.fetch in an app worker. The response uses the ordinary bundle shape; a peer value disappears when its connection closes or its declared duration expires. For stored data, GET ./api/query?q=<encoded filter> accepts the same encoded filter; the bare ?<filter> form also works for page links.
The examples use a kitchen app whose vocab.json declares
{ "$defs": {
"recipe": { "properties": {
"serves": { "type": "number" },
"minutes": { "type": "number" },
"cuisine": { "type": "string" } } },
"chore": { "properties": {
"every": { "type": "number" } } },
"reading": { "properties": {
"pages": { "type": "number" },
"started": { "type": "string", "format": "date-time" },
"done": { "type": "boolean" } } } } }beside the platform's own components — doc, task, comment, archived, created and the rest.
Filter syntax
A filter is predicates joined by &. Each one is a dot, a component, a property, an operator, a value:
.recipe.minutes<=30
^ ^ ^ ^
| | | value
| | operator
| property of that component
component
await query('.recipe.minutes<=30&?doc')
GET ./api/query?.recipe.minutes<=30&?docEvery predicate joined by & must hold. | is "or" and binds more loosely than &, so use parentheses to group: (.recipe|.chore)&?doc is recipes and chores, each with its doc, which is how a page asks for every kind it draws in one request (Performance). Any-of on one property is written inside its predicate (see Operators, below). A predicate ends where the next & begins: .recipe&.doc is two filters, where .recipe.doc would be one path.
A property is always written with its component: .recipe.serves, .doc.title. A name by itself is a component, so .serves asks for entities that have a serves component. Where no component has that name the query is refused, and the refusal names the form to write: .recipe.serves. So a component you declare can never change what another name in a filter means.
An empty filter selects nothing. There is no filter for "everything": query('') and a bare query('limit=50') both return []. To list what you saved, name a component those rows have: .doc, .recipe.
Selecting by component: ., !, ? and *
Four ways to name a component rather than one of its properties:
.recipe has a recipe
!recipe has no recipe
?recipe asks for the recipe without filtering on it
* every component this store holds (the debugging form)! and = select. ? neither selects nor excludes anything; it only asks for the component to be returned on the rows the rest of the filter selected:
await query('.chore&!completed&?doc') // chores not yet done
await query('.recipe&?doc') // recipes, with their titles? is safe for a component this store does not declare: asking for one does not assert that it exists, so .recipe&?loan returns the recipes without a loan. .loan for a component this store does not declare is refused instead, because an empty answer would misreport what is there.
* returns every component of every row it selected. Use it to inspect data, not to render a page:
await query('.doc.title~=drizzle&*')It is part of the filter, so it works wherever a filter does: subscribe() keeps running the same query, and the updates it sends carry the same whole rows.
Which components a row carries
A row carries only the components its filter named. This is the most important rule on this page: a page that ignores it draws undefined.
A row is {kind, entity: {eid}, ...those components}. So this page code is wrong:
for (let r of await query('.recipe')) {
draw(r.doc.title) // TypeError: no doc on the row
}
→ [{ kind: 'recipe', entity: {eid: '940d…'},
recipe: {serves: 4, minutes: 20, cuisine: 'american'} }]and this is correct:
for (let r of await query('.recipe&?doc')) {
draw(r.doc.title, r.recipe.minutes)
}
→ [{ kind: 'recipe', entity: {eid: '940d…'},
doc: {title: 'Pancakes', body: 'flour, milk, eggs'},
recipe: {serves: 4, minutes: 20, cuisine: 'american'} }]A component that any predicate mentions counts as named, whatever the operator: .doc.title~=cake carries the doc, .task.status=open carries the task, .created.at=today carries the stamp. Three forms do not name a component: an absence (!archived asks for rows without one, so there is nothing to carry), the * form, which asks for all of them, and a bare word, which searches the docs without naming any component to leave out, so a filter of only words returns whole entities, as id= does.
A declared property that has never been written is on the row with the value null, not missing from it, so test the value and not in. The platform's own properties read back the same way, doc.title included.
entity and kind are on every row: entity.eid is the id to write back to, and the only name an entity has here; kind is what the entity is: the first component it has, which may be one the app declares.
Three kinds of row are left out of a listing unless the filter names them: the platform's stamps (.created, .updated), the platform's own diagnostic rows (.exception, .refusal, .error), and the person rows the store keeps so a byline has a name (.person). So by default a listing returns only what your app saved. Asking for .entry also admits transcript refusal rows, but not exceptions; naming .exception does not opt in the other diagnostics.
subscribe(filter, cb) returns the same rows by the same rule, so a page can replace one call with the other and change nothing else.
Operators
.recipe.cuisine=thai equals
.recipe.cuisine!=thai not
.doc.title~=drizzle contains, case-insensitive, literal
.recipe.minutes<30 less than
.recipe.minutes<=30 at most
.recipe.serves>4 more than
.recipe.serves>=4 at least
.recipe.cuisine=thai,british any of
.recipe.minutes=20..35 a range, both ends included
.recipe.minutes=20...35 a range, the end excluded
!recipe.cuisine the property is empty or absent
.recipe.cuisine~= the property is there at allA number property compares numerically and every other property as text, which is why an ISO stamp compares correctly as text. A comparison a property's type cannot make, such as .recipe.serves>many, is refused rather than guessed.
A list and a range are values, not extra syntax: .recipe.cuisine!=thai,indian is "neither", and .recipe.minutes!=20..35 is "outside that range".
!= also matches a row that lacks the property entirely, since a missing value is not thai. When you mean "has a cuisine, and it is not thai", write both:
await query('.recipe&.recipe.cuisine!=thai&?doc')~= is a plain substring test on the stored text, not full-text search: it finds drizzle inside Lemon drizzle, and it will not stem, rank, or match across a word boundary. Bare words do full-text search (see Full-text search, below).
Value types
The platform's own properties are typed, and a value of the wrong type is refused with an error rather than matching nothing:
.task.status=finished
→ task.status is one of cancelled, done, open — got 'finished'
.filed.priority=high
→ priority is a finite number, optionally P-prefixed (P2, p02, 1.5)
— got 'high'- text — as typed.
.doc.title~=cake - number —
.recipe.serves>=4, decimals fine. - enum —
task.statusisopen,doneorcancelled. It is read, never written:.task.status=doneselects the entities that have acompleted. - priority — a number, and
Pis optional:.filed.priority<=2and.filed.priority<=P2ask the same thing. - time — a stamp or a phrase; the next section is only about those.
- url — text with a shape. Quote it (below), or the
&in its query string starts a second predicate. - eid — a reference to another entity, by its eid:
.comment.target=940d…,.filed.assignee=dc5e…,.created.by=<who.person>.
Your own properties compare by the type your vocab.json gives them:
- A
booleanis stored as1or0, andtrueandfalsename those:.reading.done=trueand.reading.done=1both find the finished ones. - A
date-timeis kept as written, so write ISO stamps (new Date().toISOString()), and compare it with a stamp or a time phrase:.reading.started>=2026-01-01,.reading.started>1-hour-ago. - A
numbercompares as a number:.recipe.serves>=4.
Time phrases
A time phrase names either a period of time or a moment. Against a period, the operator chooses which end of it is compared:
= within it .created.at=today
>= from its start .created.at>=2026-01-01
<= until its end .created.at<=yesterday
> after it ends .created.at>last-week
< before it starts .created.at<todayA moment (now, 1 hour ago, in 2 days) is compared as itself: .created.at>1-hour-ago is anything later than an hour ago, and .created.at<=1-hour-ago anything no later. = with a moment means between it and now, so .created.at=1-hour-ago is the last hour. A range runs from one phrase to the other: .created.at=yesterday..today.
The phrases:
now today yesterday tomorrow
this|last|next minute|hour|day|week|month|year
5 minutes ago 2 hours ago 3 days ago (also 5m, 2h, 3d)
in 2 days after 8h
9am 9:30pm 14:00 noon midnight 9am tomorrow
2026-07-04 a whole day
2026-07-25T09:00 a minute; with seconds, a secondWhitespace separates predicates, so join a phrase with - or _, or quote it:
await query('.doc&.created.at>=1-hour-ago')
await query('.doc&.created.at>="1 hour ago"')A phrase is evaluated each time the query runs, so a saved filter with today always means the current day. Weeks start Monday. Day boundaries are local time.
The created and updated stamps
Every row the store keeps is stamped with who saved it and when. The stamps are components of their own, so a filter must name them to get them, and a listing that does not name them leaves them out:
.created who saved it and when
.created.at when (time)
.created.by who (a reference)
.updated.at when it last changed
.updated.by who changed it
await query('.recipe&.created&?doc')
→ [{ kind: 'recipe', entity: {…},
doc: {title: 'Lemon drizzle', body: '…'},
created: {by: {eid: 'dc5e…', name: 'Maya'},
at: '2026-09-03T12:59:27.876Z', via: null},
recipe: {serves: 8} }]A reference to a person this store knows reads back as {eid, name}, so one query returns a list with its bylines. Any other reference stays a bare eid.
at and by are shared by several components, so write out which one you mean: .created.at, .updated.by, .completed.at, .archived.at. Common filters:
.doc&.created.by=<who.person>&?doc what this person saved
.doc&.updated.at>=today&?doc what changed today
.doc&.created.by&?doc the rows that have a byline
.doc&.created.at=last-week&.created last week's, with the stampQuoting values
Quotes keep a value together across both separators, whitespace and &:
await query('.doc.title~="Lemon drizzle"')
await query('.web.url="https://x.test/p?a=1&b=2"&?web')Unquoted, the & in that URL would end the predicate and start a second one. Quoting is how you avoid that; percent-encoding a value with an & in it does not survive the trip, because the HTTP endpoint decodes each part of the query string before the grammar reads it. Quote it.
A value with a space but no & works unquoted (.doc.title~=Lemon drizzle is one predicate), but only until another predicate is added to the filter. Quoting always works, so quote such values.
Following references and back-references
A dotted path walks a reference property and tests a property of the entity it points to. Every hop but the last must be a reference:
.comment.target.doc.title~=Pancakes comments on the pancake recipe
.filed.project.doc.title~=Kitchen tasks in the project called Kitchen
.assignee.title~=maya bare: filed.assignee → doc.titleA path walks a reference: the platform's comment.target, filed.project, filed.assignee, attachment.blob and the by of each stamp, and any property that a vocab.json declares with ref and death (Components and vocab.json). A text property holding an eid is only text, and a path will not walk it.
The plural form walks the other way, to the entities that point at this one:
.comments has at least one comment
!comments has none
.comments>=2 has two or more (any comparison works)
.comments.doc.body~=butter has some comment mentioning butter
.comments!.doc.body~=butter has none mentioning butter
.comments!.doc.body!=butter every comment mentions it (De Morgan)The name is the component's plural — .comments are the entities whose comment.target names this row. When a component has two reference properties the name picks which one: .tasks_project are the projects that have tasks, .tasks_assignee the people who do; .attachments are the blobs a file row points at.
// the recipes nobody has said anything about
await query('.recipe&!comments&?doc')
// whoever has been given something to do
await query('.tasks_assignee&?doc').refs is the union of all of them: everything that points at one entity, through any reference property:
await query(`.refs=${eid}&?doc`) // what mentions this
await query('!refs&?doc') // what mentions nothingCounting: .count, .tally and .distinct
Three directives return a value instead of rows. They are written beside the filters that select the rows they reduce, and they read from the index, so a page that needs a number should ask for the number, not fetch the rows to count.
await query('.recipe&.count')
→ {count: 3}
await query('.recipe&.tally=recipe.cuisine')
→ {tally: {american: 1, british: 1, thai: 1}}
await query('.recipe&.distinct=recipe.cuisine')
→ {distinct: ['american', 'british', 'thai']}.count counts what the rest of the filter selects. .tally= counts each value of one property; .distinct= returns those values themselves, sorted. Both name one property, never a path, and your own properties must be written with their component — .tally=recipe.cuisine, not .tally=cuisine.
The answer is an object, not an array, so a page has to branch on which it asked for. Ask for one aggregate at a time: with two in one filter, only the first is used.
let { count } = await query('.chore&!completed&.count')
badge.textContent = `${count} to do`Limiting results with limit
A window (limit) caps the number of rows in the answer without changing what matches.
limit=20 the newest 20 matchesA plain listing is oldest first, in the order the rows were written. A windowed listing is the newest that many, still in oldest-first order among themselves. So limit returns the first page of a feed:
let page = await query('.doc&limit=20')Both forms work (limit=20 and .limit=20 are the same window), and an invalid bound is refused rather than ignored:
.limit=abc
→ .limit takes a whole number: .limit=200A window without a filter selects nothing: limit=50 alone returns [], because an empty filter selects nothing.
Reading one entity by id (id=)
await query(`id=${eid}`)id= addresses an entity instead of selecting by component, and names no component to leave out, so it returns the whole bundle, including the stamps. To read several, use a comma list, or repeat it:
await query(`id=${a},${b}`)With a component named beside it, the ordinary rule applies: id=<eid>&?doc returns only that entity's doc. Anything the store minted an eid for can be read this way, including a blob you uploaded.
Querying one app or every app (.in=)
A page's query() reads its own app's store, and so does a worker's env.STORE. graph_query in an agent's tools reads more: signed in, it reads every app the person can reach at once, one bundle per entity. To read one app, add .in= to the filter with the app's name:
.recipe&.in=recipesWhere two of the person's spaces have an app by that name, name the space too: .in=kitchen/recipes. .in= belongs to the connector, not to the grammar, so a page or a worker never writes it. Signed out, there is no set of reachable apps to narrow: graph_query takes the space and the app as arguments instead, and refuses .in=.
Full-text search
A bare word is a full-text term over every doc — title and body — and the answer comes back ranked, best first:
await query('lemon')
await search('lemon') // the same search
await query('lemon&.recipe&?doc') // ranked, and only recipes
→ [{ kind: 'recipe', entity: {…},
doc: {title: 'Lemon drizzle', body: '3 lemons, 200g butter'},
recipe: {minutes: 50, serves: 8},
rank: {title: 'Lemon drizzle', snip: '3 \x01lemons\x02, 200g butter',
score: 2.0000017} }]A word names no component to leave out, so, as with id= above, a filter of only words returns the whole entity, the app's own components included. That lets a page render search results directly. With a component named beside the word, the ordinary rule applies: lemon&.recipe returns recipes with no titles, lemon&.recipe&?doc returns both.
rank is added to the row for a text query and is never stored. Its snip marks the match with \x01 and \x02 so a page can wrap it in its own markup; the store never returns HTML.
Terms match whole tokens: lemon does not find lemons. A trailing * prefix-matches, and quotes make a phrase:
await query('lemo*')
await query('"coconut milk"')Words and predicates can be mixed in one filter, so a search box can pass its whole string to query(), and a saved filter is a valid search.
Similarity search (.near)
Every text the store searches is also compared by meaning. .near=<eid> selects the eight entities closest in meaning to that one, among what the rest of the filter selects, and .order=similar puts the closest first:
await query(`.near=${eid}&.recipe&.order=similar`) // recipes like this oneA text just written is found this way shortly after, once its embedding has been computed; an entity with no text has no neighbours.
Unsupported filters
An app's store refuses one part of the wider platform grammar, with a message that names it, rather than doing something else:
.doc&work=build → work lanes are not served by this store.kind= knows the platform's own components only (.kind=task, .kind=comment); a component the app declares is not a kind to it, and .kind=recipe is refused. Ask for the component instead: .recipe.
This store supports the rest of the grammar.
Example filters
.recipe&?doc
every recipe, with its title and body
.recipe.minutes<=30&?doc
what you can cook in half an hour
.recipe.cuisine=thai,vietnamese&?doc
two cuisines at once
.doc.title~=lemon&.recipe
recipes whose title contains lemon — a substring, not a search, and the
title predicate already asked for the doc
.chore&!completed&?doc
the chores still to do
.chore&.completed.at=today&?doc
the chores done today
.chore&.count
how many chores there are, without listing one
.task.status=open&.tally=filed.domain
how much open work each part of the house has
.reading&.reading.done=1&.reading.started>=2026-01-01&?doc
books finished since the new year (your own bool is 1, your own time
is ISO text)
.recipe&!comments&?doc
recipes nobody has commented on
.doc&.created.by=<who.person>&.created&limit=20
the newest twenty things this person saved, with their stamps
id=<eid>
one entity, whole, however many components it hasThe whole guide is at The guide.
Searching uploaded documents
Uploaded PDFs and supported Office files become document_page{document, blob, page, location, text} rows. Their text participates in the same full-text search and .near=<page eid> searches as other searchable properties. POST ./api/documents/search with {words, semantic: true} searches the app's rows and document pages by words and by meaning together, and POST ./api/documents/ask with {words} answers from the same hits with citations. Either takes an optional scope, a filter line such as .document_page, that narrows what it reaches. Files and Uploads covers extraction, scanned pages, permissions and costs.
