Demonstration. The firm, the parties and every contract here are fictional.

OpenAPI 3.1.0, version 1.0.0

API reference

The routes behind Clause Desk, a demonstration for the knowledge team of a fictional law firm. Seeded contracts are public. An upload is private to the visitor's signed cd_anon cookie and is deleted after 24 hours; another visitor gets 404 for it, exactly as for a document that does not exist. Every error has the same JSON shape, and every response carries an X-Request-Id that matches the server log. There are no accounts and no keys.

GET/api/documents

List documents

The seeded contracts, plus the caller's own uploads when the request carries a valid cd_anon cookie. The answer depends on the cookie, so it is never cached.

Example

curl 'https://clauses.phoenixcodes.org/api/documents'

Responses

  • 200The documents the caller may read.application/json · object
  • 500Something failed on the server (internal). Quote the request id.application/json · Error

GET/api/documents/{id}/pdf

Get a document's PDF

The PDF bytes, inline. A seeded contract is cached for an hour; an upload is private, no-store and only its owner can read it.

Parameters

idstringpathrequired
A document id: a slug for a seeded contract (mutual-nda) or u- and a short random id for an upload.

Example

curl 'https://clauses.phoenixcodes.org/api/documents/msa/pdf'

Responses

  • 200The PDF.application/pdf · string (application/pdf)
  • 400The request is not valid. message says which field and why.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 500Something failed on the server (internal). Quote the request id.application/json · Error

GET/api/documents/{id}/geometry/{page}

Get a page's text and line boxes

The text of one page and the box of every line, in PDF points with the origin at the top-left. A character range from a citation becomes highlight rectangles with these. Offsets are UTF-16 offsets into text.

Parameters

idstringpathrequired
A document id: a slug for a seeded contract (mutual-nda) or u- and a short random id for an upload.
pageintegerpathrequired
1 to 20001-based page number.

Example

curl 'https://clauses.phoenixcodes.org/api/documents/msa/geometry/%E2%80%A6'

Responses

  • 200The page.application/json · PageGeometry
  • 400The request is not valid. message says which field and why.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 500Something failed on the server (internal). Quote the request id.application/json · Error

POST/api/ask

Ask a question about a document

Retrieves clauses from the one document named in the request, and streams an answer that cites them as [p.9 §11.2]. The reply is a server-sent stream in the AI SDK UI message format. Below the confidence threshold no model is called and the stream carries the single sentence Not addressed in this document. with the closest clauses in data-meta.related.

The stream's parts: start; data-meta (confidence first, and again at the end with the model, tokens, cost and timings); data-passages (every passage the model was shown, numbered, with the character ranges a viewer highlights); text-start, text-delta and text-end for the answer; error; finish. A failure after the first token ends the stream with an error part and never splices a second model's answer onto the first.

Each client gets 15 questions per 10 minutes, and the whole demo has a daily token budget that resets at 00:00 UTC.

Request body (JSON)

documentIdstringrequired
The one document to answer from.
questionstringrequired
3 to 500 charactersThe question. Whitespace at either end is trimmed before the length is checked.
sessionIdstring (uuid)
Continue a session. Omit to start one; the id comes back in data-meta.

Example

curl -N -X POST 'https://clauses.phoenixcodes.org/api/ask' \
  -H 'Content-Type: application/json' \
  -d '{"documentId":"msa","question":"What is the cap on each party'\''s liability in a contract year?"}'

Responses

  • 200The answer, streamed.text/event-stream · string
  • 400The request is not valid. message says which field and why.application/json · Error
  • 403A browser sent the request from another site (forbidden). Only this site's own pages may spend the model budget; curl and servers, which send no Origin, are not affected.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 429Too many requests from this client (rate_limited), or too many live uploads (too_many_uploads). Wait retryAfterSeconds.application/json · Error
  • 503A dependency (search, a model or the database) is unavailable (upstream_unavailable), or the daily token budget is used up (budget_exhausted, with resetsAt).application/json · Error

POST/api/upload

Upload a PDF

Takes a text PDF of at most 10 MB and 80 pages. The platform caps a request body at 4.5 MB, so the file is sent in parts of at most 4 MB (up to 3), each as the raw body of one request, tied together by x-upload-id. Every part but the last answers 202. The last answers 200 with a stream of newline-delimited JSON progress events, ending in done with the new document, or error.

The upload is private to the caller's cd_anon cookie (set on this response if the caller has none) and deleted after 24 hours. Scanned PDFs, password-protected PDFs and damaged files are refused with a reason; there is no OCR.

Parameters

x-upload-partstringheader
Which part this is, as 2/3. Omit for a file sent in one request.
x-upload-idstringheader
Ties the parts together. Required when there is more than one part.
x-upload-sizeintegerheader
1 to 10485760The whole file's size in bytes. Required when there is more than one part.
x-upload-namestringheader
The file's name, used for the title when the PDF has none.

Request body

application/pdf: The raw bytes of one part, at most 4,000,000.

Example

curl -N -X POST 'https://clauses.phoenixcodes.org/api/upload' \
  -H 'x-upload-part: 1/3' \
  -H 'x-upload-id: 7f3a9c1e-5b2d' \
  -H 'x-upload-size: 9200000' \
  -H 'Content-Type: application/pdf' \
  --data-binary @contract.pdf

Responses

  • 200Progress events, one JSON object per line, ending in done or error.application/x-ndjson · UploadEvent
  • 202A part before the last was stored.application/json · object
  • 400The request is not valid. message says which field and why.application/json · Error
  • 403A browser sent the request from another site (forbidden). Only this site's own pages may spend the model budget; curl and servers, which send no Origin, are not affected.application/json · Error
  • 413A part or the whole file is over the limit (payload_too_large).application/json · Error
  • 415The body is not sent as application/pdf (unsupported_media_type).application/json · Error
  • 422The first bytes are not a PDF's (not_a_pdf).application/json · Error
  • 429Too many requests from this client (rate_limited), or too many live uploads (too_many_uploads). Wait retryAfterSeconds.application/json · Error

GET/api/compare

Compare two documents

Aligns the clauses of two documents by text, heading and number, so a renumbered or dropped article still pairs, and returns the word-level diff of each pair, the list of changes and any saved one-line summaries. The result is rebuilt on every request; only the model-written summaries are stored. Both documents must be readable by the caller.

Parameters

leftstringqueryrequired
The earlier document's id.
rightstringqueryrequired
The later document's id. Must differ from left.

Example

curl 'https://clauses.phoenixcodes.org/api/compare?left=mutual-nda&right=mutual-nda-v2'

Responses

  • 200The comparison.application/json · Comparison
  • 400The request is not valid. message says which field and why.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 503A dependency (search, a model or the database) is unavailable (upstream_unavailable), or the daily token budget is used up (budget_exhausted, with resetsAt).application/json · Error

POST/api/compare/gloss

Write the one-line summaries of a comparison

Has a model write one sentence per change, once, and saves them for the pair. A sentence that mentions a number the change does not contain is dropped. Nothing is charged to the rate limit or the budget when the summaries already exist or no model is configured. 8 summary requests per 10 minutes per client.

Request body (JSON)

leftstringrequired
The earlier document's id.
rightstringrequired
The later document's id.

Example

curl -X POST 'https://clauses.phoenixcodes.org/api/compare/gloss' \
  -H 'Content-Type: application/json' \
  -d '{"left":"mutual-nda","right":"mutual-nda-v2"}'

Responses

  • 200The comparison with its summaries, and how many were newly written.application/json · Comparison
  • 400The request is not valid. message says which field and why.application/json · Error
  • 403A browser sent the request from another site (forbidden). Only this site's own pages may spend the model budget; curl and servers, which send no Origin, are not affected.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 429Too many requests from this client (rate_limited), or too many live uploads (too_many_uploads). Wait retryAfterSeconds.application/json · Error
  • 503A dependency (search, a model or the database) is unavailable (upstream_unavailable), or the daily token budget is used up (budget_exhausted, with resetsAt).application/json · Error

GET/api/sessions/{id}/export

Export a question session

The questions and answers of one session with every citation kept: Markdown with GitHub footnotes, or an A4 PDF typeset in the site's type and colours with the citations as footnotes. Only the visitor who asked the questions can export them; anyone else gets 404. 10 exports per 10 minutes per client.

Parameters

idstring (uuid)pathrequired
The session id, from data-meta.sessionId in an ask stream.
format"pdf" | "md"query
default "pdf"The file format.

Example

curl 'https://clauses.phoenixcodes.org/api/sessions/%E2%80%A6/export'

Responses

  • 200The export, as an attachment. Never cached.application/pdf · string (application/pdf)text/markdown · string
  • 400The request is not valid. message says which field and why.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 429Too many requests from this client (rate_limited), or too many live uploads (too_many_uploads). Wait retryAfterSeconds.application/json · Error

GET/api/eval/runs

List eval runs

Stored scorecards from pnpm eval, newest first, without the per-question results. A run with retrievalOnly: true measured retrieval and the confidence gate but had no answer model, so its answer figures are null rather than zero.

Parameters

limitintegerquery
1 to 50 · default 10How many runs to return.

Example

curl 'https://clauses.phoenixcodes.org/api/eval/runs'

Responses

  • 200The runs.application/json · object
  • 400The request is not valid. message says which field and why.application/json · Error
  • 503A dependency (search, a model or the database) is unavailable (upstream_unavailable), or the daily token budget is used up (budget_exhausted, with resetsAt).application/json · Error

GET/api/eval/runs/{id}

Get one eval run

One stored run with the result of every question.

Parameters

idstring (uuid)pathrequired
The run id.

Example

curl 'https://clauses.phoenixcodes.org/api/eval/runs/%E2%80%A6'

Responses

  • 200The run and its questions.application/json · EvalRunDetail
  • 400The request is not valid. message says which field and why.application/json · Error
  • 404There is no such document, page, session or run, or it belongs to another visitor. The two cases look the same on purpose.application/json · Error
  • 503A dependency (search, a model or the database) is unavailable (upstream_unavailable), or the daily token budget is used up (budget_exhausted, with resetsAt).application/json · Error

GET/api/cron/purge

Delete expired uploads

Called once a day by the platform's scheduler, which sends Authorization: Bearer <CRON_SECRET>. Deletes uploads past their 24 hours and abandoned upload parts. Without the secret the answer is 401. Reads of an expired upload are refused whether or not this has run; the sweep reclaims space.

Example

curl 'https://clauses.phoenixcodes.org/api/cron/purge' \
  -H 'Authorization: Bearer $CRON_SECRET'

Responses

  • 200How many uploads were removed.application/json · object
  • 401The bearer secret is missing or wrong.application/json · Error
  • 500Something failed on the server (internal). Quote the request id.application/json · Error

Schemas

The shapes the routes above send and return. AskMeta and Passage travel inside the ask stream as the data-meta and data-passages parts.

AskMeta

The data-meta part of an ask stream. Sent first (confidence, before any model call) and again at the end with the outcome.

sessionIdstring (uuid)required
questionIdstring (uuid)required
answerIdstring (uuid)required
documentIdstringrequired
confidencenumberrequired
0 to 1
thresholdnumberrequired
0 to 1
gateDeclinedbooleanrequired
The confidence gate stopped the request before any model was called.
declinedbooleanrequired
The reply is Not addressed in this document. (gate or model).
relatedinteger[]
With a decline: the numbers (Passage.n) of the closest clauses.
provider"anthropic" | "workers-ai"
modelstring
promptTokensinteger
completionTokensinteger
costUsdnumber
0 on Workers AI.
ttftMsinteger
Milliseconds to the first streamed token.
latencyMsinteger

Passage

The data-passages part: one retrieved clause chunk, numbered in rank order.

nintegerrequired
at least 1
chunkIdstringrequired
documentIdstringrequired
labelstringrequired
The reference the answer writes between brackets, e.g. p.9 §11.2.
pageStartintegerrequired
pageEndintegerrequired
clauseIdstring | nullrequired
Primary clause number of the chunk.
clauseIdsstring[]required
headingstringrequired
breadcrumbstringrequired
rangesTextRange[]required
The character ranges of the clause's own text, per page.
excerptstringrequired

UploadEvent

One line of the upload's progress stream. stage runs received, reading, indexing, saving, then done or error; progress only goes up.

stage"received" | "reading" | "indexing" | "saving" | "done" | "error"required
progressnumber
0 to 1
doneinteger
indexing: chunks embedded so far.
totalinteger
indexing: chunks to embed.
documentDocumentSummary
done: the new document.
errorobject
error: why it stopped.
error.code"invalid_request" | "invalid_id" | "not_found" | "unauthorized" | "forbidden" | "rate_limited" | "budget_exhausted" | "upstream_unavailable" | "payload_too_large" | "unsupported_media_type" | "not_a_pdf" | "scanned_pdf" | "encrypted_pdf" | "too_many_pages" | "too_many_uploads" | "internal"required
error.messagestringrequired
Safe to show to a person.
error.requestIdstring
error.retryAfterSecondsinteger
rate_limited and budget_exhausted: seconds until a request can succeed.
error.resetsAtstring (date-time)
budget_exhausted: when the daily budget resets (00:00 UTC).

DocumentSummary

idstringrequired
titlestringrequired
docTypestringrequired
partiesstring[]required
summarystringrequired
versionGroupstring | nullrequired
Documents sharing a group are versions of one another.
versionintegerrequired
pageCountintegerrequired
kind"seed" | "upload"required
seed contracts are public; upload ones are private and expire.
expiresAtstring (date-time) | nullrequired
When an upload is deleted; null for seeded contracts.

Comparison

leftDocumentSummaryrequired
rightDocumentSummaryrequired
rowsCompareRow[]required
changesClauseChange[]required
statsCompareStatsrequired
glossobjectrequired
gloss.state"ready" | "pending" | "unavailable"required
ready: every change has a summary. pending: a model can write them (POST /api/compare/gloss). unavailable: no model is configured.
gloss.modelstring | nullrequired
The model that wrote the summaries.
writteninteger
POST only: summaries newly written by this call.

CompareRow

One aligned clause pair, or an added or removed clause.

keystringrequired
Stable anchor within one comparison: r0, r1, …
kind"same" | "added" | "removed" | "changed"required
leftLabelstring | nullrequired
rightLabelstring | nullrequired
leftClauseIdstring | nullrequired
rightClauseIdstring | nullrequired
headingstringrequired
articlestringrequired
leftPageinteger | nullrequired
rightPageinteger | nullrequired
leftDiffSegment[]required
The earlier version's words: same and del segments.
rightDiffSegment[]required
The later version's words: same and ins segments.
renumberedbooleanrequired
The clause has a different number in each version. A renumbering alone is not a change.
scorenumberrequired
0 to 1Match confidence of the pair.

ClauseChange

One entry of the What changed list.

kind"added" | "removed" | "changed"required
clauseIdstringrequired
headingstringrequired
leftPageinteger | nullrequired
rightPageinteger | nullrequired
glossstring
One model-written sentence, present only when a model wrote it.
leftClauseIdstring | null
rightClauseIdstring | null
anchorstring
The row to jump to.
digeststring
Short hash of the changed words; a saved summary is reused only while it matches.

EvalRun

One stored scorecard. A figure the run could not measure is null, never zero.

idstring (uuid)required
createdAtstring (date-time)required
gitShastring | nullrequired
Short commit the run was made from.
modelstring | nullrequired
Model that wrote the answers; null when none ran.
judgeModelstring | nullrequired
Model that scored faithfulness and correctness.
retrievalOnlybooleanrequired
Only retrieval and the confidence gate ran: no answer model.
notesstring | nullrequired
What a reader needs to interpret the numbers, e.g. how the embeddings were made.
confidenceThresholdnumber | null
promptVersionstring | null
judgePromptVersionstring | null
questionCountintegerrequired
outOfScopeCountintegerrequired
pageHitAt1numberrequired
0 to 1
pageHitAt3numberrequired
0 to 1
clauseHitAt1numberrequired
0 to 1
clauseHitAt3numberrequired
0 to 1
mrrnumberrequired
0 to 1Mean reciprocal rank.
declineAccuracynumber | nullrequired
0 to 1Out-of-scope questions that ended in a decline.
gateDeclineAccuracynumber | nullrequired
0 to 1Out-of-scope questions the gate stopped before any model call.
falseDeclineRatenumber | nullrequired
0 to 1Answerable questions declined anyway.
faithfulnessnumber | nullrequired
1 to 5Mean judge score: is every claim supported by the passages.
correctnessnumber | nullrequired
1 to 5Mean judge score against the reference answer.
citationRatenumber | nullrequired
0 to 1Answers whose every citation points at a passage the model saw.
latencyP50Msintegerrequired
latencyP95Msintegerrequired
ttftP50Msinteger | null
ttftP95Msinteger | null
promptTokensinteger
completionTokensinteger
totalTokensintegerrequired
costUsdnumberrequired
Billed cost of the answers; 0 on Workers AI.

EvalQuestionResult

idstringrequired
g01 to g30 (gold) or o01 to o10 (out of scope).
kind"gold" | "out_of_scope"required
questionstringrequired
documentIdstringrequired
expectedPageinteger | nullrequired
expectedClausestring | nullrequired
retrievedobject[]required
The passages the model was shown, in rank order.
pageRankinteger | nullrequired
Rank of the first passage on the expected page.
clauseRankinteger | nullrequired
Rank of the first passage with the expected clause.
confidencenumberrequired
declinedbooleanrequired
gateDeclinedbooleanrequired
passbooleanrequired
answerstringrequired
validCitationsintegerrequired
totalCitationsintegerrequired
faithfulnessinteger | nullrequired
correctnessinteger | nullrequired
judgeNotestring | nullrequired
latencyMsintegerrequired
ttftMsinteger
promptTokensintegerrequired
completionTokensintegerrequired
costUsdnumber
providerstring
modelstring
errorstring

Error

Every error body has this shape.

errorobjectrequired
error.code"invalid_request" | "invalid_id" | "not_found" | "unauthorized" | "forbidden" | "rate_limited" | "budget_exhausted" | "upstream_unavailable" | "payload_too_large" | "unsupported_media_type" | "not_a_pdf" | "scanned_pdf" | "encrypted_pdf" | "too_many_pages" | "too_many_uploads" | "internal"required
error.messagestringrequired
Safe to show to a person.
error.requestIdstring
error.retryAfterSecondsinteger
rate_limited and budget_exhausted: seconds until a request can succeed.
error.resetsAtstring (date-time)
budget_exhausted: when the daily budget resets (00:00 UTC).