Skip to main content
Draft — ahead of the backend; nothing here is live yet. This surface is written against the design, not against a running server. There is no api.contracts.io to call, no key to issue, and every response below is an illustration rather than a recording. It is published so the shape can be argued with before it is built.It also predates CONTRACT-SCHEMA v0 and still uses the older nouns — ask for Proposal, receipt for Version, relationship for Room. The glossary maps them.
One endpoint, two chairs. The Agent home calls it with 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:read
ask: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 · contract
string
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

With stream: 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.
A stream that ends with neither done nor error is a transport failure. Treat it as incomplete, never as an empty answer. Show the reader that it broke, and let them ask again.

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.
Any count you render comes from matched, or from counting the citations yourself. Never parse a number out of the answer prose. The prose carries its own generated count, and the two can disagree inside one response. The generator is handed matched.contracts so the prose does not drift, and matched is still the only number to print.
An ask is a ranked retrieval, not a guaranteed exhaustive scan. Say so in your client. “Three of the 41 agreements you can open” is honest. “We checked all 41” is not.

The answer is its citations

Every claim carries contract id + block id + char span + the quoted words. That is what lets a client render “3 MSAs cap at 6 months, open each” as three real doors instead of a paragraph the reader must trust. A sentence that cannot cite says so.
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.
Each row carries both a lexical index and an embedding, keyed to the 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 the words_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.
  1. The keyword half is transactional. Its row is written in the same transaction as the words themselves, so it is never stale.
  2. The embedding half is asynchronous, usually less than a second behind.
  3. A row whose words_hash is 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.
  4. Every citation carries the words_hash it was built from, so a client can compare it against the contract it opens and tell the reader the words have moved since.
The practical consequence is small and worth saying out loud. For about a second after a counter lands, a question about a concept can miss the new words while a question about an exact phrase already finds them. Nothing ever quotes the old ones.

Retrieval runs on your seat

A contract the asker cannot open cannot be retrieved, cannot be cited, and cannot be counted.There is no service-role index reader.
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.
Unsent replies, staged whys and internal notes are never retrievable across parties. Both sides can open a shared contract, so “can you open this contract” is one level too coarse a question here. Clause blocks are shared by construction. Letters and notes are not: a sent letter is shared, a round you have staged and not sent is yours, and an internal note never leaves your side. An ask is a summary, and a summary of the other side’s private draft would be the hardest kind of leak to notice, because the answer reads just as fluently either way.

Asking about a contract you cannot open

POST /ask with scope: "contract" on a contract this seat cannot open returns 404 not_found, exactly what a contract that never existed returns.
The two answers are identical on purpose. A 403 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.
The question is asked in English, answered with citations, and narrowed with the index’s existing facets.