hunch.sql makes a spec a SQL function in DuckDB: it takes the columns the spec reads, and returns each question’s answer.
Register a spec
hunch hook install copies the command guard into hunch/, with 38 real agent commands a panel labelled. Query them:
state columns, in order. It returns a struct with one field per question, each holding what judge() returns for one row: label, p (the probability of that label) and route (act, review, or empty when the question has no act). review means the guard’s bar for acting alone, 0.9, wasn’t met: five of the seven are for a person to look at, and two it is sure of.
DuckDB hands the function up to 2,048 rows at a time, and each batch is one hunch run: requests go out together, and every answer is kept in the store like any other. The first query sent Jev 38 requests, one per distinct command, for $0.001 in about a second. Run it again and it takes 0.06 s and asks nothing. hunch run on the same rows afterwards found all 114 answers already there: SQL, batch and judge() share one cache.
It is still measured
The panel’s labels are a column, so checking the function is a query:hunch test hunch/command_guard.yml reports for destroys. The function answers with the spec, so everything hunch knows about the spec (its tests, its reviews, what a change would flip) is about this column too.
A budget, not a surprise
select … from big_table asks for every row. max_cost is the most the function may be charged in total, in US dollars, across every query on the connection and every judgment and escalation in it. Each request reserves its worst case before it is sent, so the charge stays under the cap; a query that reaches it fails with DuckDB’s error around hunch’s message, and the answers it got are saved, so the next query over those rows is free. Without max_cost, HUNCH_MAX_COST is the total; with neither, there is no cap.
A distilled spec answers on your machine for $0, which is what makes a decision on every row of a large table cheap.
In dbt
With dbt-duckdb, hunch is a plugin. Inprofiles.yml:
profiles.yml
models/command_risks.sql
dbt run built it in 0.13 s from the store: 38 commands, 7 that destroy something, 9 that send data out, 12 that need a look. From here it is an ordinary table: dbt tests it, documents it, and downstream models join it.
Details
- Arguments. The columns the specs read, in the order
hunch.sql.columns(hunch.load(path))lists them: every judgment’sstate, then any column awherereads. - Projects. Register a folder of specs and the function is named after the folder. It returns a struct of judgments, each a struct of questions, a union included. A judgment a row never reached, because its
wheresaid no, is NULL. Amultiquestion gives one field per option,topics__refundand so on. - Inputs are text. Every argument is
VARCHAR, as a CSV cell is. Read CSVs withall_varchar = true: DuckDB otherwise guesses types, and1.50comes back as1.5, a different input with a different cache entry. A NULL argument is sent as an empty string, as an empty cell is byhunch run, so the answers and the cache are the same. A row whose arguments are all NULL gets NULL. - Rows repeat. Identical rows are asked once, and a row judged before, by any route, costs nothing.
- Rows are told apart by position, never by the spec’s
key, which can repeat in a table. - Inside async code (Jupyter, FastAPI) it works as anywhere else: hunch runs its requests on a worker thread.
- Install.
uv add "hunch-ai[sql]"brings DuckDB, PyArrow and numpy. dbt needsdbt-duckdbas well. Spec paths in the dbt plugin are relative to where dbt runs.