Currently in alpha. Sign up free. Expect a few rough edges.
Docs

Models

SQL kept under a name: a view that runs when it's read, or a table built after its sources land or on a schedule, with tests. Download it all as a dbt project from Settings, Company.

A model is a SQL query kept under a name, which can read other models with {{ ref('other_model') }}; models run in the order they read each other. Each is kept as a view (its SQL alone, worked out whenever something reads it), a table (its rows built and kept), or an incremental table (only rows newer than the last build are added, by a column that only goes up, such as a time). It is shaped like dbt on purpose: a company that outgrows this can run real dbt on the same files, from Download as a dbt project in Settings, Company; Take everything beside it packs the whole account, tables and dashboards too (Take everything with you).

A test is a query that must find no rows, with one plain line, if whoever saved it wrote one, saying what it checks. A model's Tests tab lists them: the line (else the name), the last result and when it was checked; open one for its SQL, the rows it found and its recent results, with Run it, Edit and Remove. When one fails, the first few rows it found are kept, shown only to someone who can read every table it reads with real values; anyone else sees how many failed. Every build tests the whole new table before it takes over, whoever starts it (a sync landing, a schedule, Run now, ovs run, a push, an agent), so a failing test never replaces the good table: the live rows stay as they were.

A build rebuilds only what changed: a model whose SQL, tests and inputs are as they were at its last good build is marked unchanged, and Run now uses that build again. An incremental table has Rebuild from scratch. A model is behind when its SQL changed or a table it reads was written since, and its page says which. A model that reads a table is built again on its own after that table lands, a connected source's tables included, so a model on shopify.orders is rebuilt after every Shopify sync, and the build says what it came after. A model set to run by hand, or one a job builds, waits for that instead. The table it reads can be in another dataset: a model that reads a sheet kept in sheets is rebuilt after that sheet is pulled, and writes only its own dataset. A build that comes out the same rows as the table already holds writes nothing: its run says unchanged, the table keeps its version, and the models built on it are not run again.

With workers switched on, a build you ask for runs on one of your company's workers: a table as it is published, Run now, Rebuild from scratch, ovs run, a push and an agent's build each go in your company's line ahead of the scheduled work, and a napping worker wakes for it in about a second. The line under Publish says where it is, Waiting for a worker, then Building on worker-2, then its rows. While a build of a model waits or runs, its page says the same under its name, with See the run, and the run's own page says where it was built. Behind another build of the same dataset it waits its turn, and says so. A view is made at once, since it holds no rows of its own. A company with no workers yet, or with workers switched off, builds on its own machine.

A table model reads all of its history again at every build, after each sale too, and nothing moves it anywhere else. When its last build after new rows took a second or more, its page says so under its SQL, with how long. Where its SQL keeps rows, one for each id with a time that only goes up, Make it incremental sits beside Edit and Query. It asks first, then saves it as an incremental table that reads only the rows past the newest it holds. A row its sources delete then stays in it until you rebuild it from scratch. Adding up over all of history, like each customer's spend over every order, is never offered, since the new rows alone cannot say it. Keep the rows in a model and add them up in a metric. A Python model off the instant rule says the same, since it runs its script over every row again, and Run it on each landing puts it on the instant rule. Then each landing in a table it reads, a sale in a connected store's orders too, runs its script with only those rows, whichever dataset the model is kept in, and adds what it returns to its table. A model that asks the AI about each row asks about the new ones alone.

One kind of model is left alone: one last saved with a key that sees a dataset it reads masked, or cannot read it. It stays as it is and shows as behind until whoever saved it builds it again, so a masked key's model never fills with real values on its own. A Python model on the instant rule is left the same way: a landing in its table runs no model whose saver sees another dataset it reads masked.

The Models list says each model's state in a word with its time (Up to date · 2 min ago, Failed · 1 min ago, Not updated, Behind its sources, Not built, Running), how many of its tests fail, its last seven runs as a strip, when it refreshes (6:00 am, Monday, 7:00 am, after its sources land, when you run it) and its rows. One line under the title names what needs you, and only then ("2 need you: returns_rate failed and wholesale_aging was not updated."). The company's own models come first; the tables a connected source builds in sit under one fold below them.

A model's page says what it is and how it stands: its name (with Python beside it for a Python model), one sentence of what it holds, one line of state with a dot, green when it is up to date ("Up to date · ran today at 6:05 am, after its sources landed · 2,224 rows"), amber when it is behind its sources and red when its last run failed or a test fails, and under it who saved it and the version. A failed run is said in plain words when the app knows the mistake ("what it reads has no column called refund_amount; it has d, gross_margin, units"), with Edit SQL beside the line; Run now stands there when it is behind or not built yet. When a test found rows on a new build, the line says Not updated: its test found 2 rows, and whose numbers are still showing; the button beside it says what pressing it means, Those rows are fine, update it, and asks first. Its Overview is one page: what it reads and what reads it, one hop each side, drawn as the Map's cards with their state (a card opens its page), and where its table goes, the destinations it is sent to, among what reads it, each with its state ("Sent 2 min ago" and the rows so far, grey when the send is paused, red with its own sentence and the time when it failed); the SQL (a Python model's script) read as code, with line numbers, the editor's colours and no wrapping (a long line scrolls sideways), the first eighteen lines with Show all under them, Copy, and Expand, which opens it full screen (Esc closes it), with Edit and Query under it, the editor taking its place while you edit, a Python model's script highlighted while editing too; the columns, with what each holds where someone wrote it; the tests as rows, the plain line (else the name), the last result and when, with the first row a failing test found under it, and Add a test and Run the tests; and its notes when it has some. Everything else it can do is under the ··· at the top: Run now, Query, When it runs, Rebuild from scratch (an incremental table) and Delete. Its other tabs are Runs, Versions (which also says when the definition came from a folder that a push will overwrite) and Closes.

The Map opens on the models: every dataset holding a model the company made is drawn card by card, with the metrics, the dashboards and where their tables go, the destinations they are sent to, at the right edge, and a connected source's dataset is one card until you open it. Each card says its state in the same words as the model's page ("Landed today 6:02 am · 36 rows", "Up to date · 2,232 rows · 2 min ago", "Not updated · 7 rows", "Failed · 1 min ago" with the mistake under it), and a destination's card says where it goes, what goes and its state in the words of Destinations ("Sent 2 min ago" with the rows so far, "Paused", "The last send failed" with its own sentence under it), with Open going to the send on Destinations; a card you pick keeps what feeds it and what reads it bright and dims the rest, and the mini map in the corner shows the whole with the window over it. Runs is every build, newest first: a job's or a model's, its state, what it built ("5 of 6 built · 1 not updated", "1 of 1 · 55 rows"), when, how long, and who started it ("By hand · Sarah", "After the Shopify sync", "On its schedule"); every sync, check and send is behind Show every run. A build's page opens with a band that says what to do about the first step that did not pass, with the one button in it (a model its test held: Those rows are fine, update it and Edit the test; a failed one: the mistake in plain words and Edit SQL), one line of counts, the clock, and a table of steps, each with its state and what it wrote; a step's row opens its log and tests below. Jobs shows each job's last five runs as a strip, and when it runs next, or Paused.

Delete is under the ··· on a model's page, and asks first: the model stops being built and leaves Models, and the table it last built (or the view) stays in Warehouse until someone drops it. The models that read it are named, since they fail at their next run until they are changed. A removed model leaves its jobs, and a job left with nothing to build is paused.

Views, and what Free allows

A view is a model kept as its SQL alone. It has a name, it's listed in Warehouse beside the tables in its dataset, and anything reads it the way it reads a table: a query, a metric, a dashboard, an agent. It runs when something reads it and holds no rows of its own, so it takes no room: its page and the Models list say how many rows it read the last time it ran ("reads 2,230"), its page says when it was saved, and its history is every version of its SQL. It's ready as soon as it's saved, in the app or with ovs push; if it can't be read yet, because it reads a table that isn't there or its SQL has a mistake, the save says why. A table is built: its rows are worked out and kept, rebuilt after what it reads lands or on a schedule, with a history of every build. After a webhook it is rebuilt as soon as the webhook lands. After a sync or a file it is rebuilt once the tables it reads have been quiet for 45 seconds, whatever else in your warehouse is still landing; a table that never pauses has what reads it rebuilt every five minutes on the rows that have landed. It can be incremental, or written by a Python script.

On Free every model is a view. Saving one as a table, an incremental table or a Python model is refused with one sentence that says so, wherever it's saved: in the app, with ovs push, or by an agent, and nothing changes. A model saved without saying what kind is a view, and the answer says so; in the app a new model's Output is View. A cursor and a key are an incremental table's, so a view doesn't use them, and the answer says that too. A view holds no rows of its own, so whenever it is read it shows its sources as they are; on Free nothing else runs on a timer but the daily pull of the connected systems.

Self-serve and Done for you add tables, incremental tables, Python models and schedules. Moving to one changes no model by itself: a view stays a view until someone saves it as a table. A company that moves from one of them to Free keeps its models as they were: a table can still be changed and saved as a table, or saved as a view. A connected source's own tables are tables on every plan.

Python models

A Python model is a script whose rows become its table, on Self-serve and Done for you. It reaches nothing outside unless an admin lists the hosts it may reach, and then only those, through Overscore's proxy, with every run's log saying what went where. Keys for those services are pasted once and never shown again. It reads the tables it names and the kept files it names. It imports what it needs from overscore_model: rows("sales.orders") or read(...) for a table, file("lead_model") for a kept file, log(...) for a line in the run's log, note(...) for a note on the model's table that its readers see (a correction it found, say; written only once the build's rows go in), live() for the rows its own table holds now, as the last good build left them, and output(rows) once, for the result.

Hosts are for enrichment: each customer's city placed on a map, the company behind an email's domain, a score from a service of your own. On the model's form, under Requirements, May reach lists them, one a line (api.geocod.io), and Keys for those services takes each key by its name (GEOCODIO_KEY), which the script reads from its environment (os.environ["GEOCODIO_KEY"]); a saved key shows as set, with Replace. Only an admin, signed in or on their own laptop, changes either: an agent's key, the owner's included, may change the script but not where it reaches, and its save is refused whole, the script too. A name that points at a private or internal address is refused when it is saved and at every connection. The model's page says where it may reach and who set it ("May reach api.geocod.io · set by Sarah, Oct 21"); a run's log says what it may reach first and what went where last ("reached api.geocod.io: 18 connections, 195 KB"), and a host not on the list is refused by name ("api.other.com is not on this model's list"). In a folder, model.toml's hosts says the same; the keys are never pulled or pushed.

ask("read", instructions, text, Shape) asks Claude a question and gets the answer back in the shape you give: a Pydantic class or a JSON schema. "read" asks Haiku; "judge" asks Sonnet, for a harder call. It goes through Overscore, never from the script itself, and spends your company's AI inside the same monthly limit as a chat. It is AI on its own too, so it also stops when an admin turns AI on its own off in Settings, Company, or at its ceiling for the month, and the ask gets a sentence saying so. One run may spend $1 of it unless the model's own ceiling (ask_usd when it's saved) says otherwise; past it, the next ask is refused with a sentence saying so. The same question asked again is answered from what was kept, at no cost. Each ask is a line in the run's log, with the model, the tokens and the cost. Ask where things are, never for the numbers themselves: the answer points at a cell, and the script reads the number from the cell.

cells("weekly_report") reads a kept spreadsheet (.xlsx, .xlsm or .csv) cell by cell, for a report laid out for people rather than as a table: one row per cell that holds something, with its address (B110), its row and column, its value, what kind of value it is (a number, text, a date, yes or no), and its formula when it has one. A formula cell gives the value the file last worked out, and nothing in the file ever runs, macros included. cells("weekly_report", versions="all") gives every kept version of the file, each cell once per version. The model must name the file among the files it reads; a model may read files alone, with no table.

emails("weekly_report") reads every email a file someone emails kept, oldest first: when it came, who sent it, its subject, its words line by line, its files (each with its name, what kind of file it is, and its bytes beside the script), and what the address made of it. versions="live" gives the newest alone. The model names the emails among the files it reads (weekly_report_emails), once the first email has come, and each new email runs it. What an email means is the model's to say: a report typed into the body (ask Haiku which line is which, and copy each figure out of its line), or an expected file that did not come, as a check that fails.

A new version of a kept file runs every Python model that names it, at once, with its tests, and then the models that read that one, with theirs: the run says it ran because the file got a new version. Each version is read as the file stood when it landed and checked in turn, so one that lands a few seconds behind another is never skipped. The same file uploaded again unchanged starts nothing. On Free nothing runs, since a model there is SQL.

A report that arrives as a spreadsheet

Some numbers come as a report someone sends each week: a spreadsheet laid out for people, with a title, a date, headings on two rows, a month per row, the year written only now and then, a year-to-date row after each year and notes off to the side. Loaded as a table it reads as one cell. Two Python models read it instead.

The first keeps the file as cells: cells("weekly_report", versions="all"), output as it comes, one row per cell of every version, so each week's file is kept beside the last. The second reads the report into one row per month, with the cell each figure came from and the report's own date as its "last updated". It goes by that date, not by the order the files came in: an older week sent again, or put in late, changes nothing, and its run says so.

That second model can find the months two ways, chosen by one line at the top of its script. The code way follows rules: a row is a month when its first cell is a month's name, the year carries down and moves on at each January, and the revenue column is the "Total" nearest "Revenue". The AI way hands Haiku the cells, row by row, and asks where things are: which column, which rows are months and of which year. If one way stops working when the report changes, switch to the other and run it again (ovs model run and the model's name, or Run now).

Either way the model points and the code reads: the AI never returns a figure. It names a cell, and the script reads the number from that cell, so every number traces to a cell in a dated file.

The same four tests hold either way, as the model's own tests: every year's months add up to the year-to-date figure the sheet itself shows; every month from the first to the report's month appears exactly once; every prior month matches last week's file, the one the table shows; and a past month whose figure changed is said, in the run's log and, once the version goes in, in a note on the table ("Mar 2026 revenue changed from 412,300 to 412,950 in the file of 10/19"). A month the last file was in the middle of finishes; it is not a change. A row inserted, a note typed beside a month or a column moved still reads the same. A month row deleted, or a month already finished that is different now, fails the tests: the table and its date stay as they were until the next good file, or until a person who may change the data presses Those rows are fine, update it on its card or the model's page (ovs model take and the model's name), which takes exactly the rows the tests found and checks the next file against them.

Where they come from

You write them, your agent writes them, or a chat keeps one when you say yes: a query worth keeping, as a model with a description; Versions is a table of every saved definition, newest first: what it changed (the line whoever saved it left, else how many lines changed), who saved it and when, with Show older past the first 100. Open one to see its SQL with the lines it added and removed marked, and Go back to this version, which saves it again as the newest. Each night a query people keep running becomes an Inbox card, and Keep it as a model makes it one. Asked to build something that already exists, the assistant offers the existing one first and builds another only if you say so.

If someone saved a newer version while you were editing, the save says who and when, and you choose, look at theirs, save over it, or take theirs. On a laptop, ovs push says pull first and ovs pull merges.

A connected source's tables (shopify.orders) are built-in models, made by the sync and marked as ours. After a pull or a webhook only the records that changed are written again. A sale replaces its own order and its line items, so it is in shopify.orders as quickly in a store of 500,000 orders as in a store of 50. A pull that changes much of a table builds it whole, which is quicker then, and so does a new field. The Models list starts with your own models, the ones you or the chat made; the built-in ones sit under one line below them, Built in, from your connected sources, with how many there are. Click it to open them, and it stays open or shut as you left it; a search or a state filter looks inside it too. Open one to read its SQL or build on it; saving over it is refused, so save yours under a new name.

What an agent builds on top follows the house rules (Settings, Company): dbt's names out of the box, stg_shopify__orders for a cleaned copy, fct_orders and dim_customers for what people read, every layer in the analytics dataset while the sources keep their own, one job per model, a description and a test each. ovs push of a new model that names no dataset (no --dataset, none in the folder or its .toml) is refused with that rule. Once a model has been pushed with --dataset, the folder remembers where it went: ovs status, ovs pull and the next ovs push find it there without the flag. The line under a model's name says which job builds it, as a link. Jobs is a table: what each job builds, when it runs, how its last run went and when it runs next. A paused job keeps saying how its last run went ("Failed · 2 days ago · paused"), so pausing a job never hides that it failed. A run that worked is Passed on Models, Jobs and Runs alike, and one a person started is By hand. Open one for what it ran lately, with Run now (it opens the build), Edit, Pause and Remove; Every run of this job lists only its builds. Change the rules and it builds your way.

Lineage

The Map draws the whole account: sources, the tables they land, the models on top, saved queries, metrics and dashboards, and the destinations they are sent to at the right edge, with the data flowing left to right and each card showing its state, rows, last run and whether it is keeping up; a model, dashboard or destination carries the worst state of the visible data feeding it. Datasets and sources start folded; open one to see inside. Type a name with a plus on either side (+orders+) to see what feeds it and what reads it; no matches leaves an empty Map with Clear search. Drag the graph with a mouse or a finger to pan. Click a card and see what it is, its state, rows, last run, how many tests are failing, and what feeds it and what it feeds. A key sees, and is told the state of, only what is in the datasets it reads.

A job builds with the access of whoever set it up. If their key is revoked, or they leave, the job pauses at its next run with a card in your Inbox saying whose it was, and a build already waiting for it does not run. Turning the job on again makes it yours: from then it builds with your access.

Commands

ovs models sales
ovs run sales            # every model in the dataset, in order
ovs test sales
ovs pull                 # the models, saved queries, metrics and dashboards as files; ovs push saves them back

When any model or test fails, ovs run and ovs test exit 1 and the JSON says "success": false, with every model's result kept. A model that failed keeps the table it built last, so a script that checks the exit code never carries on with a table that did not refresh.

ovs run says what each model did: built now, or built earlier and kept because nothing it reads has changed, with when, and whether that was when it was saved (a pushed view or table is built as it is pushed). A table that comes out the same rows as it holds is not written again: ovs run says the same rows: nothing written, and nothing built on it runs. ovs test names the model each test is for and the rows that failed it. A test written in a model's .toml belongs to that model, so two models can each have a test named order_id_unique.

Where your company has workers switched on, ovs run and ovs model run build there and wait for it, saying on stderr where the build is as it moves (waiting for a worker, building on worker-2), and then where it was built (built on worker-2). ovs push waits for each table it makes the same way, and its line for that model ends with the worker it was built on.

All docs · Something wrong on this page? Tell us.