https://api.vaquill.ai/workspace/v1, and every call carries Authorization: Bearer vq_ws_....
Two calls, two scopes
Building the grid and running it are deliberately separate. A create writes one pending cell per document per column and costs nothing; a run makes one model call per cell and costs money.matrices:write buys the first and matrices:run buys the second, so a credential that assembles matrices for a human to approve cannot spend anything. Reading needs matrices:read.
Creating a matrix
title is required. description, documentIds and columns are all optional, and this is not a launch, so it does not take Idempotency-Key.
Answers 201 with the whole matrix, including the minted rows and columns with their ids. Nothing is extracted yet.
Columns
Each column carries alabel (the heading), a question (the prompt run against every document), and a columnType.
The columnType vocabulary is free_text, single_select, date, money, yes_no and party_name. There is no text, number or boolean; those are the three guesses that produce a 422. free_text is the default. options is required when the type is single_select and ignored otherwise, and instructions adds guidance scoped to that one column, for example how to treat a missing value.
The question is the prompt, so write it the way you would ask a paralegal. “Liability cap” as a question yields worse answers than “What is the aggregate limitation of liability, and what does it apply to?”.
At most 60 columns per matrix. Documents times columns is the number of billable extractions a run performs, so the two ceilings multiply and it is worth doing that arithmetic before you create the grid.
Rows
Every id indocumentIds must be in the matter named in the path and must have finished ingesting. A still-ingesting document is refused rather than queued, because retrieval over a document with no chunks yet returns a confident “not in this document” for every column, which is one paid call per cell to learn nothing and is indistinguishable in the result from a genuine absence.
A duplicate id is refused rather than silently de-duplicated. It would otherwise mean two identical rows you are billed for twice.
Row order is the order you named the documents in, not database order.
Rows and columns are not fixed at create. You can add and remove both afterwards, reorder the columns, and edit a question. See Editing the grid below, and note the one edit that clears answers.
Finding a matrix again
{data, pagination} as everywhere. Each row is the header only: id, matterId, title, description, status, createdAt, updatedAt.
No cell counts and no grid. Those are three extra queries per matrix, so a page of 200 would be 600 round trips on the cheapest read this API offers. Use the list to find an id and GET .../matrices/{matrixId} to read one.
This is the matrices filed to this matter. Every matrix created through this API is one, because the create always names a matter in its path. A matrix somebody built in the web app without filing it to a matter is not visible here; 38 of the 64 matrices in production today are in that state.
Running it
force. This is a launch, so it takes Idempotency-Key and answers 202 with an operation of type matrix.run. Poll GET /v1/operations/{operationId}.
The operation’s progress counts cells, with unit: "cells". Read the unit rather than assuming: {done: 43, total: 100} means something very different for cells than for documents, and total is the whole grid rather than only the part this run touches, so a re-run’s progress bar does not go backwards.
What a re-run does to existing cells
An empty body re-runs every cell that has not produced an answer: cells that never ran, cells that failed, and cells that ran and found nothing. Cells that already hold an answer are left alone, so re-running after a partial failure is cheap and safe. An answer you wrote, approved or rejected is not re-run and not re-charged. That holds at three separate layers of the pipeline, so it is a property you can build a review workflow on rather than an accident of ordering.{"force": true} re-runs everything, including cells that already answered, and overwrites them with no warning anywhere in the response. That includes a reviewer’s edits and their approvals. It is the only way to pick up a question that was edited after the first run, and it is charged for every cell.
Re-running is idempotent per cell rather than additive: a cell is unique on its row and column, so a second run replaces the answer in place instead of adding a second one.
Reading the grid
columns and rows inline, plus cellCount, pendingCellCount and errorCellCount. Rows and columns are inlined because a cell names only a rowId and a columnId; without them a client would have to page the whole grid to interpret a single answer.
Watch pendingCellCount for completion and errorCellCount for cells that failed. They are different questions, and neither is the same as a cell that succeeded with nothing to report.
Reading the cells
limit runs from 1 to 200 and defaults to 50; offset defaults to 0. The response is {data, pagination} with limit, offset, total and hasMore. Cells come back in creation order, which is row-major across the grid.
A cell is:
id, rowId, columnId and status are guaranteed. answer, confidence and extractedAt are null until the cell has been extracted, and citations is empty until then. Resolve rowId and columnId against the matrix’s rows and columns to know which document and which question a cell belongs to.
A matrix archived in the web app while a run was pending reports that run as
cancelled. Nothing further happens to it, and there is no separate archived status on this API: the five public values are all there are.Cell status
A cell uses the same five values as an operation. The distinction worth writing code around is between the twosucceeded rows below, and between them and failed.
Treating a null answer as an error is the most common way to misread a matrix. Across 200 leases, “no termination clause found in this one” is frequently the answer you were looking for.
Citations are verified, which is why an answer can be missing
Every citation carries aquote, and optionally the page it appears on and the chunkId it came from.
Before a cell is saved, each quote is checked to be a literal substring of the passage it cites. Quotes that fail that check are dropped, and they are dropped silently because a fabricated quote is not evidence of anything. So a citation you can see here has been checked. That is the property that makes it worth publishing at all.
The check then feeds back into the answer itself. If the model claimed an answer but no citation survived verification, or if an unverified quote leaked into the answer text, the cell is downgraded: it comes back succeeded with a null answer instead of with a confident, ungrounded one.
This is a deliberate trade. A matrix over 200 leases will show you a few blanks that a human would have filled in, and in exchange no cell asserts a term with a quote that is not in the document. If you need the borderline cases, look at
confidence on the cells that did answer and read the low ones yourself.Editing the grid
Four operations change the axes, and every one of them answers with the whole matrix, not with what it created. That is deliberate: each of them changes the cell count, and the newpendingCellCount is the number you need before deciding whether to run.
All four carry matrices:write. None of them starts extraction.
Adding rows
201 with the updated matrix. Each new row gets one pending cell per existing column, so adding two documents to a 30-column grid adds 60 extractions to the next run.
Same two rules as the create: every document must be in this matter and must have finished ingesting. Plus one more: a document that is already a row is refused with 409 matrix-not-editable, and the problem carries enrolledDocumentIds naming which. That refusal is not pedantry. The insert underneath filters duplicates out rather than failing, so without it a request naming five documents of which two were already rows would answer 201 having added three, with nothing saying which two it dropped.
Removing a row
204. The document’s answers in this matrix go with the row; the document itself stays in the matter.
Adding columns
201 with the updated matrix. Each new column gets one pending cell per existing row, so one column on a 40-row grid is 40 extractions. Same column vocabulary and same 60-column ceiling as the create, counted against what the grid already holds.
Editing a column
Reordering columns
columnIds is the complete order and has to name every column in the matrix exactly once. A subset is refused with 422 naming what was left out, and so is a repeat. A reorder that names some of the columns is not a reorder; it is an unstated rule about where the rest go.
This is the only way to move a column. position is not writable on a column patch.
Removing a column
204, unless another column is conditional on this one, which is 409 matrix-not-editable with dependentColumns naming them.
Deleting a row or a column leaves a gap in
position rather than renumbering. Positions are ascending and not contiguous; use them for ordering and never for addressing.Conditional columns
A column can be set to run only when another column answered a certain way. It is the cheapest thing in the product: a gated-out cell costs nothing, and an ungated one costs a model call per document. “Only ask the indemnity questions of contracts that HAVE an indemnity clause” is the shape. It takes two calls, because a dependency names a column id and a column has no id until it exists:gateExpression is a closed vocabulary, not free text:
Anything else is refused with a
422. That refusal is the point of the vocabulary being closed: the evaluator fails closed, so an expression it cannot parse does not error, it marks every cell in the column “deliberately skipped” for every document, with nothing anywhere saying why.
A gated-out cell reads as cancelled on the cells list, which is how you tell it apart from one that was never reached (queued) and one that ran and found nothing (succeeded with a null answer).
A dependency naming a column outside this matrix is 404. One that would close a cycle is 422.
Writing an answer, and reviewing one
Either one makes the cell survive the next default run. That is the property that makes a review pass worth doing: correct twenty cells, re-run the matrix for the rest, and your corrections are still there and were not charged for.
{"force": true} bypasses it.
The cell keeps reading as succeeded, because the extraction itself did succeed. There is no sixth status for “a human touched this”.
Citations cannot be written. A
quote on this API carries the promise that it was checked to be a literal substring of the passage it cites, and there is no such check on a quote you supply. Publishing the field would turn a true statement about our data into a false one, silently and permanently, so it is not published. Neither is answerData, which is where the verifier’s own record lives.The extractor’s statuses cannot be written either. review is approved or rejected and nothing else, because an integration writing extracted onto an answer no machine produced makes the whole grid unauditable.Verifying one answer
It costs a model call, so it carries
matrices:run rather than matrices:read and sits in the launch rate-limit tier. It is synchronous: there is no operation to poll, because there is nothing to fan out.
409 cell-not-verifiable means the cell has no answer, or an answer with no surviving citation. There is nothing to check and no evidence to check it against.
Verify the cells you are going to rely on, not the whole grid. On a 200-row matrix, confidence on the cells that answered is the cheaper first filter.
Exporting the grid
csv or xlsx. content is base64 and sizeBytes is the length of the decoded file, so you can size storage without doing base64 arithmetic.
Bytes, not a signed URL, and this is one of only two operations on the whole API that work that way. A matrix export is rendered per call and stored nowhere, so a URL would mean writing your work product into our storage first: encrypted, and undecryptable to you, or in the clear.
includeCitations is on by default and adds one extra column per question holding that answer’s supporting quotes. It is what makes the file checkable by somebody who was not watching the run.
413 export-too-large above 10 MiB. Export without citations, or split the matrix. A 250-row, 60-column grid with citations is comfortably inside it.
Cells starting with
=, +, - or @ are neutralized before they reach the file, so an answer extracted from a hostile document cannot execute as a formula when somebody opens the export in Excel. This is a matrix over documents a third party sent you; that is not a theoretical concern.Deleting a matrix
204. Hard, with no trash and no undo. The cells, columns, rows and every answer go with it, along with any comments, history or webhooks configured on it in the web app. A workflow run that produced this matrix survives and loses its reference to it.
409 operation-in-flight while a run over the matrix has not finished. Poll the operation named in the problem first. Deleting mid-run would leave that run’s operation reporting running until a sweeper marked it failed hours later, with a code that is supposed to mean a worker died.
Delete is not idempotent. A second delete of the same id answers 404. On a delete that timed out, treat a subsequent 404 as success.
Sizing a run
Two ceilings, both refused with413 run-too-large before anything is written or charged. The problem document carries limit, requested and unit, so you can batch without discovering the ceiling by bisection.
Cells is rows times columns, so 200 documents times 30 questions is 6,000 and the next question over the line is refused. Nothing is truncated to fit: starting the first 6,000 cells and reporting success would hand you a grid that looks finished and is not, which no field in the response could tell you about.
Split a run that is too large by building more than one matrix over subsets of the documents.
Concurrency
429 concurrency-limit-reached means three matrix runs are already in flight, which is the cap. A run holds its slot from the 202 until the last cell finishes.
It is not the same thing as the rate limiter. The rate limiter counts requests per minute; this counts unfinished jobs, and an integration well inside its request budget can still hold the whole worker pool with three fan-outs of thousands of cells each. Wait for a run to finish, or poll the operations you already started, then retry. Nothing was written, so the retry is a fresh launch.
What is not here
No cancel. Once a run is accepted it runs to completion. Cancel is being added as one verb across every capability rather than one route per capability, because a published operation can never be withdrawn and shipping a matrix-shaped cancel now would commit this API to two ways of stopping work. There is one consequence worth knowing about: a run cancelled in the web app marks its remaining cells as errors carrying a cancellation note, and the extractor then refuses to re-run exactly those cells. A plainmatrices.run over such a grid would enqueue work, do nothing, and report success. So it is refused instead, with 409 matrix-not-runnable telling you to send {"force": true}, which does re-run them and does charge for them.
No duplicate. You already hold every column spec, because you read it off a Matrix response, so the same result is a matrices.create with that list: one call, a title you chose rather than Copy of ..., and explicit provenance.
No matrix templates. All eight templates in the product are ours, not any customer’s, so publishing them would publish our catalogue under a name that reads as yours. Send the questions you want asked; that is a request body you can version in your own repository.
No column wizard. POST /columns/from-prompt in the web app turns a sentence into suggested columns for a person staring at an empty grid. A backend automating this already knows what it wants asked.
No cell comments, no cell history, no matrix chat. Collaboration primitives with no headless meaning.
No run-status endpoint. The operation IS that, with the same five statuses and a cell-counted progress. A second poll shape would be a second vocabulary reaching you.
No webhooks, anywhere on this API. Polling the operation is the completion path.
