scope: "org". In-room, the agent calls it with
scope: "contract" and widening is a visible scope chip, not a different stack.
If the two ever diverge, they will disagree, and the room’s answer will be the wrong one. It is
deliberately the same HTTP call.
Ask a question
endpoint
Scopes:
ask:readask:read is its own scope (reconciled 2026-08-24). contracts:read alone no longer buys the ask.
Corpus-wide retrieval crosses every contract the seat can open, and a key granted to read one contract
should not silently read across the org. Retrieval is still capped by the acting person’s row-level
access regardless of scope. The scope decides whether you may ask, the seat decides what can answer.string
required
Plain English. There is no filter DSL and there will not be one.
string
required
org · relationship · contractstring
A
rel_… or con_…. Required when scope is not org; null otherwise.boolean
default:"false"
When
true, the response is text/event-stream. Defaults to false. A caller who does not ask
for a socket does not get one.Without streaming
Request
200 application/json
Both forms are first-class (reconciled 2026-08-24). The non-streaming body was previously shown
only under a
stream: true request, which read as an SSE frame and left every non-streaming caller
guessing. Anything that cannot hold a socket open (a cron job, a webhook handler, a server-side ATS
enrichment) asks with stream: false and gets the whole answer in one body.Streaming
Withstream: true the response is text/event-stream carrying four event types:
Citations arrive first by design, so the list of doors is standing before the answer finishes writing.
Streaming changes when the fields arrive, never which ones: concatenate the
answer.delta chunks,
collect the citation events, take the counts from done, and you have the non-streaming body back
byte for byte.
Exactly one of done or error ends every stream
The stream opens with a 200, so a retrieval or generation failure after the first byte needs an event
of its own. Without one, a half-written answer and a finished one look identical apart from a missing
done, which is also what a dropped socket looks like.
Three counts, three meanings
The response reports three numbers, and they answer three different questions. Reporting only one of them claims something the retrieval never promised.object
truncated: true says the ranking was cut, which is the honest signal that a longer answer existed.object
Derived from the citations that came back, so it can never disagree with the doors on screen.
The answer is its citations
Do not render the prose without the citation list. The prose is the summary; the citations are the product.Retrieval, and the one thing it must never do
Per org, three corpora are indexed side by side:Clause blocks
The current-text fold, so the index can never quote words that lost.
Facts
Name, value, occurrence. What turns “six months” from a fuzzy match into an exact one.
Letters
Notes, asks and
whys. Where the reason a term moved actually lives.words_hash it was built from and
swept when that hash moves. A question runs both rankings, fuses them by reciprocal rank, reranks the
top slice, and hands ≤12 blocks to the model with their contract ids attached.
Hybrid is not hedging. It is the two halves of a contract question. The number is lexical; the
concept is not.
How fresh the index is
Every index row is keyed to thewords_hash it was built from, and swept when that hash moves. The two
halves of the index are not equally fresh, and the difference decides whether words that landed a moment
ago can be quoted as the words that stand now.
- The keyword half is transactional. Its row is written in the same transaction as the words themselves, so it is never stale.
- The embedding half is asynchronous, usually less than a second behind.
- A row whose
words_hashis not the contract’s current hash is left out of the ranking. It is not served with its old text, and it is not quietly re-read at citation time either. A re-read would quote today’s words under a ranking earned by words that lost. - Every citation carries the
words_hashit was built from, so a client can compare it against the contract it opens and tell the reader the words have moved since.
Retrieval runs on your seat
searched.contracts counts only contracts this seat can open, and your UI should say so in those words.
There is deliberately no withheld: 2 field, and there never will be: a count of what you cannot see is
itself a leak.
Asking about a contract you cannot open
The two answers are identical on purpose. A403 would confirm the contract is real, which lets somebody
outside guess ids and sort the real ones from the invented ones.
Errors
The envelope, the status of every code, and the rest of the list are on Errors.
What died here
- A separate analytics or BI query language over contracts: saved queries, a filter DSL, a reporting endpoint. A second query language would be a second permission surface and a second thing to keep in sync with the words.
- A search tool taking a filter DSL instead of a question.