API reference
The same doors over HTTP. Send your key as a bearer token.
Base address: your app address, for example https://. Every request sends Authorization: Bearer ovs_…. Bodies and answers are JSON. The CLI is a thin layer over exactly these routes, so anything it can do, a script can do.
Read
GET /whoami who this key acts for, its datasets and levels
GET /plan today's meters, the month's on Free, the background time, the AI limit and the machine, as ovs plan prints them
GET /ai/who AI this month by who asked for it: people, the AI on its own apart, sorting as a count (month=YYYY-MM); GET /ai/who/person (who=) one person's chats; GET /ai/who/own (by=) one runner's things
GET /index every table by tier, one line each
GET /index/{dataset}/{table} columns, samples, freshness, lineage
GET /tables GET /tables/{dataset}/{table} GET /schema
POST /query {"sql": "select …", "max_rows": 1000, "offset": 1000}: at most max_rows rows (1,000 unless asked, 10,000 at most), from offset on; truncated says more follow
GET /find/{dataset}/{table}?q= words in a table of messages or tickets
GET /context?q= one search over facts, notes and tables
POST /research/web {"questions": ["…"], "hosts": ["docs.example.com"]}: the researcher on the web, in the background
GET /research/{run} its note: the findings, a source under each claim, the docs pagesA machine runs four queries at once. When all four stay taken for two seconds, POST /query and a dashboard's own doors (/dashboards/{slug}/data and /numbers) answer 503 with Retry-After: 2 and {"busy": true, "retry_in_s": 2, "running": 4, "error": "busy: four queries are already running on this machine, try again in 2 seconds"}. Nothing ran, a write neither: wait the seconds it names and ask again. Busy is not down. A download (POST /query/download) waits its turn instead.
A page sends the asks it fires at once as one request, POST /dashboards/{slug}/batch with {"asks": [...]}, each ask as /dashboards/{slug}/data takes it, and is answered {"answers": [{"ok": true, "data": ...} | {"ok": false, "error": ..., "status": 400}]} in the same order: one turn for the whole, its asks run four at a time inside it, one request of the key's minute, and busy is one 503 for the batch. A refused ask in it is answered in its own words and the others are still answered.
For a grouped viewer with Proof off, POST /dashboards/{slug}/data leaves the calculation’s SQL and source details out of number, query and row answers. Values, dates, comparisons, trust warnings and allowed rows stay available. Editable table forms are unchanged. Its receipt has refresh_on_change: true so the dashboard runtime asks again on page change notices without using source names. Admins, builders and groups with Proof keep the full receipt.
Put data in
POST /in/{dataset}/{table} one JSON object or a list; the table is created on first post
POST /files/{dataset}/{table} CSV, TSV, Excel, JSON, Parquet; ?mode=replace or ?mode=add onto a table that has rows (refused without one)
POST /files/preview multipart file, dataset, table: what it would make, what the table holds now, what it changes in the columns and what reads it
POST /sheets/{dataset}/{table} {"file_id": "…", "tab": "…"} a sheet you picked, read as you; or {"url": "…"} a sheet anyone can open
GET /google DELETE /google GET /google/start?use=sheets&back=/data/files GET /google/sheets/{file_id}/tabs
GET /files GET /files/{id} GET /files/{id}/open GET /files/{id}/download DELETE /files/{id}
POST /files multipart file, use=file (name, dataset) or use=table (dataset, table, mode)
GET /files/search?q=&dataset= pages of documents whose words match, with a snippet
GET /files/{id}/text?pages=1-3 a document's text by page POST /files/{id}/reread read it again
POST /hooks GET /hooks DELETE /hooks/{token}
POST /connectors/{name} {"key": "rk_live_…"} and the like
PUT /links/{dataset} POST /links/{dataset}/copy DELETE /links/{dataset}POST /metrics/{name}/run accepts compare: "previous" or compare: "year" and returns the comparison on the receipt, with the same dates and calculation used by the app. The other fields are by, period, filter, params, run and limit. An unknown field is refused, so a misspelling cannot silently change the question.
A metric receipt's refresh names failed model refreshes, their run ids and the last good publication time; Home reads GET /models/health for the failures this key can see. Its incomplete and young lists flag periods still moving or too early to trust, including a period requested as one number without a time split. Keep those warnings beside the figure. A comparison has its own first_day, last_day and maturity lists under receipt.compare; display those dates to identify the comparison period, and preserve a warning from either side. Its generic label is a fallback when dates are missing.
PUT /metrics/{name} returns trust, a plain trust_note explaining the saved status and how a person can confirm it, and metric_page, relative to the account's app route. An agent key, even with write access, saves a guess (heard). Only a signed-in admin or nominated confirmer can confirm a definition; supplied confirmation names and dates are ignored. A new metric that works out the very number one on file does is refused (400), naming that one, unless its values carry differs: one sentence saying how it is a different number, kept on the version. The answers of PUT /models/{dataset}/{name} and PUT /dashboards/{slug} carry known when anything is on file about what they read: one line per defined metric, fact or note, with its name and trust.
Models and definitions
GET /models GET /models/{dataset}/{name}/versions?before=<last id> PUT /models/{dataset}/{name} POST /run/{dataset} POST /tests/{dataset} GET /models/export
GET /metrics PUT /metrics/{name} POST /metrics/{name}/run POST /metrics/{name}/check
GET /metrics/{name}/page the metric's history, where it came from, what uses it, pages linked or kept
GET /graph/{dataset} lineage as nodes and edgesPUT /models/{dataset}/{name} takes sql and kind: view, table or incremental (with cursor and key). Left out, the kind is the model's own, else the plan's. On Free that is a view, the only kind Free takes: another is refused with a 403 and the reason, and the answer's said says when a view was chosen for you, or when a cursor or key went unused. GET /plan lists the kinds a model may be (model_kinds).
Context and the inbox
GET /context/cards POST /context/cards/{id}/approve|ignore GET /context/handled GET /inbox/count POST /metrics/{name}/confirm
GET /inbox/cards?section=issues the cards as an agent reads them, each with its message, what was found, the fix it offers, what an alert looked into and its run or alert, never a key or an address
GET /runs/{id}/log a run's steps and the end of its log, the source's own keys taken out
GET /later POST /later POST /later/{id}/status POST /later/from-card/{card}
GET /reviews GET /reviews/{id} POST /reviews/{id}/answer POST /agent/chats/{chat}/turns/{turn}/review GET /people PUT /settings/confirmers
GET /origin/{kind}/{ref} where a fact, note, metric or card came from; ?whole=1 for a fact or a note: the original, every version, reads
POST /context/arrivals something heard, to be turned into a card
GET /context/facts/{table} PUT /context/facts/{table}/{key}
POST /interview POST /interview/{conv}GET /context/cards answers with what waits on a person, one row per thing. Most rows are one card. The things heard in one interview or chat, or read from one document, are one row: its id starts with g, group is facts, from says where they were heard and members holds each card with ticked (a plain yes takes it) and, when it needs more than a yes, needs. POST /context/cards/{id}/approve or /ignore on that id takes {"ticked": [ids]} (left out: every ticked member) and answers with done, left, a result for each member and a message; the rest stay waiting on the same row, and a member's own id answers it alone. The metrics nobody has confirmed are one row, id metrics, with them under metrics; POST /metrics/{name}/confirm confirms one as it stands, for an admin or a named confirmer. A metric whose number moved is one card whose proposed.moved lists each changed month with what it was and is; the figures are left out for a key that cannot read the data behind the metric. A row the app words itself carries proposed.row: its name, type and what is asked.
To page through every visible row, use GET /context/cards?paged=true&feedback=1&limit=25&offset=0. Optional search matches a row's words, its lines included, and section selects one kind, such as feedback. The answer has cards, the filtered total, waiting_total across all visible waiting rows, sections, offset, limit and handled_total, how many times something closed itself this month. Include feedback=1 to include answer feedback. Omitting paged=true answers with the rows alone. For the count and nothing else, GET /inbox/count?feedback=1 answers {"waiting_total": n}, the same figure, without reading the rows. GET /context/handled lists what closed itself this month: rows, one per thing with its name, type, what happened, when, how many times (n) and the cards behind it, and total. A card the app closed has the status handled; a metric's review question has asked.
A Later item includes chat, your saved working chat id when one exists. POST /later/{id}/status with {"status":"session","chat":"ac_…"} links your working chat before changing the status. Without chat, it reuses your saved chat or creates an empty assistant chat; this does not call a model. Setting the status to open keeps the chat link. POST /later/from-card/{card} also accepts feedback cards by their id.
Saved chats
GET /agent/chats?paged=true&limit=25&offset=0&search=August
GET /agent/chats/{id}The paged list returns chats, total, offset and limit. Search matches the title and words in the person's questions or the assistant's answers across the whole visible history. Increase offset by limit for another page. Omitting paged=true keeps the existing short-list response. Both lists keep the signed-in person's access rules.
A turn (POST /agent/chats/{id}/stream or /say) can carry where, what the person is looking at as they send: {kind, name, of, path}, its kind one of table, view, entered, query, dashboard, model, sources, source, inbox, home, files or page, with the dataset, table or dashboard it names. The chat keeps the last one as facts.where; the app's Chats screen opens that page again from it (Open beside). A query's where may carry its sql and its result (columns, the first rows, row_count), a dashboard's its cards ([{label, value}], its figures as the page drew them), a model's its dataset and model, a source's its dataset and source, an Inbox card's its card: the chat is handed them beside the person's words, read as the person. For a person in a group, any place but their own dashboards (a table, a query, a model, Sources, the Inbox), and a dashboard not shared with their group, is named and nothing of it is read. In GET /agent/chats/{id} a person's message that was sent with one has where: {kind, line}, the words after "Looking at". POST /dashboards/{slug}/build with {"find": true} answers the dashboard's own chat, or {"chat": null}, and starts none.
A table's card: PUT /index/{dataset}/{table}/card takes description (what it is) beside one_row_is, use_for and columns. GET /index/{dataset}/{table} says whose words they are: description_trust, card_trust and each column's meaning_trust, heard for a guess an agent filed and confirmed for a person's. When a chat puts a table's meanings to the person, the turn's facts.suggestions holds one of kind words (They're right); a turn sent with accepted: {kind: "words", ids} files those words as the sender before the model runs: confirmed when they write the dataset.
A saved chat can include a separate role: "warning" entry for a final number-check notice. Live chat events carry the same notice in number_warning. The saved turn's receipt.number_warning keeps it with the record. Activity and CLI record turns expose number_warning, and a feedback card's about.number_warning keeps it separate from the answer excerpt. Show the notice beneath the answer as plain text, separate from the assistant's Markdown; do not add it to the assistant's words.
A saved chat's role: "tool" line carries made when that call saved an email or an alert: {kind, name, title, state}. The live tool event carries the same. The app draws a card from it with the way to the thing (an email's draft: Open the draft). A job in jobs that ran a send carries sync, the send's id.
Your fields on a table
GET|PUT /work/{dataset}/{table}/fields POST /work/{dataset}/{table}/mark GET /work/{dataset}/{table}/history
POST /dashboards/{slug}/mark {"dataset", "table", "keys", "values"}: a mark set on that page, for anyone who may see itA key that reads the dataset marks its rows at the work door. A mark set on a dashboard (a WorkCheck or WorkStatus beside a row) goes through the page's own door: anyone the page is shared with may set the marks its code draws, and their name goes beside the mark.
Tables you fill in
POST /tables/{dataset}/{table} {"columns": ["visit_date:date", "store:link:quickbooks.customers", {"name": "rep", "type": "one_of", "allowed": ["Nina", "Sarah"]}], "one_row_per": ["store", "visit_date"]}
GET /tables/entered every table you fill in, each column with what it holds
GET /tables/{dataset}/{table} its columns; `entered` says how each was made
POST /tables/{dataset}/{table}/rows {"row": {"visit_date": "2026-10-08", "store": "Ember & Oak"}} or {"rows": [...]}; a store by its id or its name
PUT /tables/{dataset}/{table}/rows/{id} {"values": {"notes": "…"}} DELETE /tables/{dataset}/{table}/rows/{id}
GET /tables/{dataset}/{table}/rows newest first GET /tables/{dataset}/{table}/rows/{id}/history GET /tables/{dataset}/{table}/choices/{column}?q=
POST /dashboards/{slug}/rows PUT|DELETE /dashboards/{slug}/rows/{id} through a page's form and its list of the rowsA value that doesn't fit its column is refused with the reason (400) and nothing is written. Adding, changing and deleting are for a key that writes the dataset; a row is changed or deleted only by an admin or whoever added it (403 otherwise). Someone a group limits reaches the rows only through a dashboard whose page has the table's form or its list.
Dashboards and reports
GET /dashboards PUT /dashboards/{slug} POST /dashboards/{slug}/publish POST /dashboards/{slug}/unpublish GET /dashboards/{slug}/numbers
POST /dashboards/{slug}/data one of a page's asks (a saved query by name, a number) POST /dashboards/{slug}/batch {"asks": [...]} the asks a page fires at once, as one request
GET /dashboards/{slug}/versions/{v}/changes what version v changes against the live one: calculation and presentation
POST /dashboards/{slug}/download {"ask": the table's own, "columns": [{"key", "label"}], "search", "sort": {"key", "dir"}, "format": "xlsx"} what a table shows, as a file
GET /dashboards/{slug}/real-time PUT /dashboards/{slug}/real-time {"on": true}
POST /dashboards/{slug}/listen a page's line to Ably for an hour: a token that hears only what you may read GET /dashboards/{slug}/catch-up where its tables stand, its small tables' too
GET /dashboards/{slug}/slices which of the page's numbers could be worked out in the browser, the small tables they would read, each with a ticketed address (GET /d/t/{ticket}/slice/{id}, an Arrow stream, 30 minutes) for a reader of its dataset in full, and whose the plan is (scope: the company and the reader); for the rest, why they stay on the server. A ticket opens only the version it names: 409 once the slice is built again
GET /dashboards/{slug}/trace?number= GET /dashboards/{slug}/numbers/{key}/trace POST /dashboards/{slug}/numbers/{key}/run POST /dashboards/{slug}/numbers/{key}/rows
POST /dashboards/{slug}/versions/{v}/screen GET /dashboards/{slug}/screen POST /dashboards/{slug}/screen/{id}/ok
GET /saved-queries PUT /saved-queries/{name} POST /saved-queries/{name}/run DELETE /saved-queries/{name}
POST /reports GET /reports GET /reports/{id} POST /reports/{id}/email {"to": ["Nina"]}
GET /alerts PUT /alerts/{name} POST /alerts/{name}/run POST /alerts/{name}/enabled POST /alerts/{name}/mine DELETE /alerts/{name} GET /alerts/{name}/runs
GET /alerts/{name}/look what the alert finds when it looks into it now, by code (whether the data under it is fresh, syncs against their usual, which part moved, whether the definition changed)
GET /emails PUT /emails/{name} POST /emails/{name}/preview POST /emails/{name}/test POST /emails/{name}/send POST /emails/{name}/schedule POST /emails/{name}/mine DELETE /emails/{name} GET /emails/{name}/sendsPOST /alerts/{name}/mine and /emails/{name}/mine take {"gets": false} to stop your own copy and true to start it: a person's key only (signed in, or ovs login); an agent's key has no copy. Every alert and email lists gets, who gets it now.
POST /dashboards/{slug}/publish (the latest version, or {"version": 4}) publishes at once: nobody has to have had the version on screen. It is refused only for a version that is not there, or one that waits for a person to press Load. GET /dashboards/{slug} carries publish while the newest version is not the live one: ready, the few words in short when it is not, and opened, whether anyone has had it on screen, as advice only.
Analyses
POST /analyses/start {"title": "…"}: open one; send its id as X-Overscore-Analysis and the queries and metrics you run are kept in it
POST /analyses/{id}/exhibits {"kind": "table|bar|line|kpi", "ask": {"metric": "…", "by": "…", "period": "…"} or {"saved_query": "…"} or {"sql": "…"} or {"step": 2}, "title": "…"}
POST /analyses/{id}/save {"conclusion": "…"}: saved whole; [[exhibit:N]] in the words puts that table or chart where it sits
POST /analyses {"chat": "ac_…", "title": "…"}: one of your chats kept as an analysis
GET /analyses GET /analyses/{id} GET /analyses/{id}/exhibits/{n} GET /analyses/{id}/exhibits/{n}/trace
POST /analyses/{id}/again POST /analyses/{id}/pick-up POST /analyses/{id}/versions {"conclusion": "…"}
POST /analyses/{id}/exhibits/{n}/run POST /analyses/{id}/exhibits/{n}/rows POST /analyses/{id}/exhibits/{n}/page {"dashboard": "…"} a table's Run it again, Show the rows, Put this on a dashboard
POST /analyses/{id}/email {"to": ["Nina"]} POST /analyses/{id}/share {"groups": ["Reps"]}X-Overscore-Analysis, an analysis's id sent with any request, keeps what that request runs in the analysis as a step, while the analysis is yours and still open. When it is not (saved already, or someone else's), nothing is kept and the answer's X-Overscore-Analysis-Said says why. ovs sends it while the project folder's .overscore/analysis names one. Running again, picking up and a new version are the maker's or an admin's; sharing is an admin's.
Downloads
POST /query/download {"sql": "select …", "format": "csv|xlsx|parquet|json"}: every row as a file
GET /query/downloads/{id} GET /query/downloads/latest
GET /saved-queries/{name}/download?format=csv|xlsx
POST /dashboards/{slug}/download a table on a page (above), or {"number": the page's own ask, "format": "xlsx"}: the rows behind a number, up to 20,000
POST /analyses/{id}/download the whole work as a zip POST /analyses/{id}/exhibits/{n}/download {"format": "csv|xlsx"}
POST /agent/chats/{chat}/panels/{panel}/download {"format": "csv|xlsx"} a table or a chart a chat drewThe saved query's and the dashboard's doors answer with the file itself. The others write the file first: a small one before the answer, which carries url, a link that works for ten minutes; a big one answers 202 with its id, and GET /query/downloads/{id} says how far it has got until it is done, each time with a fresh link. Only you can fetch yours, and a file is kept for a day. Every file counts toward your plan's daily allowance for data sent out; at the allowance the answer is 429 and nothing is counted.
Live links
POST /exports {"dataset": "…", "name": "…", "sql": "select …", "format": "csv"} GET /exports DELETE /exports/{token}A live link is an address that always returns the query's current result as a file, with no sign-in. It answers only while an admin has live links on (Settings, Company), and stops when its key is revoked or the link is deleted.
What was done in your name
GET /actions?since=2026-09-01&of=actions|queries|steps the newest 1,000 as JSON, with how many there are GET /actions?since=2026-09-01&of=queries&format=csv|xlsx every row as a file
An admin's key. Each row says who did it and the key it came in with (key, how it starts, and key_name), newest first. since is a day or a time in seconds; left out, 90 days ago. A file's size counts toward your plan's daily allowance for data sent out, as a download's does; at the allowance it answers 429. The admin actions are kept 90 days; the query log and the steps are kept for now.
People and groups
GET /people POST /people {"email": "…", "role": "viewer|builder|admin", "group": "Reps"} DELETE /people/{email} GET|PUT /people/{email}/group {"group": "Reps"}
GET /groups POST /groups {"name": "Reps", "folders": ["Sales"], "dashboards": ["wholesale"], "metrics": ["net_sales"]} GET|PUT|DELETE /groups/{id}An admin's, except that anyone may read the people's names and addresses (who a check can be sent to) and their own group. POST /people saves the role against the address, so it applies from the first sign-in (a viewer with a group is narrowed to it; starter is the Viewers group, every published dashboard; everything alone makes a builder); the invitation itself is sent from Members, People. A list given to PUT /groups/{id} is the whole list from then on; one left out stays as it is.
Agents
POST /mcp the MCP address; add it to Claude Code, Cursor or any MCP client
The whole list
GET /openapi.json describes every door and what it takes. On your Overscore it is for an admin's key (send it as above); it is not public, so nobody looking for a way in is handed the map. /docs on your address is this documentation.