README.md

September 19, 2026 ยท View on GitHub

Vector Gateway Interface logo

vgi-typesafe

TypeSafe System One questions โ€” choice,
noul and score โ€” as DuckDB table functions you can LATERAL join against.
A VGI worker, built by ๐Ÿšœ Query.Farm

CI Live API License: MIT

Python 3.13+ DuckDB VGI vgi-python >= 0.34.0

Ruff mypy strict 473 offline tests, 26 live vgi-lint 100/100, assurance L2 behavioural


Install and attach

Nothing to clone โ€” uvx fetches and runs the worker, and DuckDB speaks to it over the VGI extension.

INSTALL vgi FROM community;
LOAD vgi;

ATTACH 'typesafe' (TYPE vgi,
  LOCATION 'uvx --from git+https://github.com/Query-farm/vgi-typesafe vgi-typesafe');

-- Your key, from https://typesafe.ai. Redacted in duckdb_secrets().
CREATE SECRET (TYPE typesafe, api_key 'ts-...');

Start here

Ask one question about one value. The answer comes back as columns, not prose:

SELECT choice, confidence
FROM typesafe.main.choice('My package never arrived and tracking has not updated',
    instructions => 'Which team should handle this?',
    criteria => MAP {'shipping': 'Delivery status, delays, lost packages',
                     'billing':  'Charges, invoices, payment problems',
                     'returns':  'Exchanges, refunds, wrong or damaged items'});
-- shipping | 1.0

confidence is how concentrated the model's probability distribution was. It is the thing that makes this usable in production: it tells you which rows you can act on without a human.

Classify a whole table

Put the function in a LATERAL join and the same question runs for every row. One request per row, issued concurrently, and repeated values are asked once:

SELECT t.id, c.choice AS team, c.confidence
FROM tickets t,
     LATERAL typesafe.main.choice(t.body,
         instructions => 'Which team should handle this?',
         criteria => MAP {'shipping': 'Delivery status, delays, lost packages',
                          'billing':  'Charges, invoices, payment problems',
                          'returns':  'Exchanges, refunds, wrong or damaged items'}) c;

Route the confident ones, keep the rest for a human

This is the pattern worth stealing. Judge once, then split on confidence:

CREATE TABLE routed AS
SELECT t.id, t.body, c.choice AS team, c.confidence
FROM tickets t,
     LATERAL typesafe.main.choice(t.body,
         instructions => 'Which team should handle this?',
         criteria => MAP {'shipping': 'Delivery status, delays, lost packages',
                          'billing':  'Charges, invoices, payment problems',
                          'returns':  'Exchanges, refunds, wrong or damaged items'}) c;

SELECT * FROM routed WHERE confidence >= 0.8;   -- auto-route these
SELECT * FROM routed WHERE confidence <  0.8;   -- queue these for review

Ask several things at once

Each row is one API request no matter how many questions it carries, so asking three things costs the same as asking one. Mix the question types freely:

SELECT t.id,
       a.team.choice     AS team,
       a.team.confidence AS team_confidence,
       a.urgent.noul     AS urgency,        -- 0..1; near 1 is yes
       a.severity.score  AS severity        -- 0..2 on the scale below
FROM tickets t,
     LATERAL typesafe.main.ask(t.body, questions => {
         'team':     {'type': 'choice', 'instructions': 'Which team should handle this?',
                      'criteria': {'shipping': 'Delivery status, delays, lost packages',
                                   'billing':  'Charges, invoices, payment problems',
                                   'returns':  'Exchanges, refunds, wrong or damaged items'}},
         'urgent':   {'type': 'noul',  'instructions': 'Does this need a reply today?'},
         'severity': {'type': 'score', 'instructions': 'How severe is this?',
                      'criteria': ['minor question', 'disruptive delay', 'critical outage']}}) a
ORDER BY a.severity.score DESC;

Judge the whole row, not one column

When several columns matter together, pass the row itself. The model sees the field names, which is how it knows a tier of gold relates to the message:

SELECT t.id, a.urgent.noul AS urgency
FROM tickets t,
     LATERAL typesafe.main.ask(t, questions => {
         'urgent': {'type': 'noul',
                    'instructions': 'Does this need a reply today? Weigh the customer tier.'}}) a;

Filter directly, with no join

For a single yes/no, is_true() is a scalar โ€” it goes wherever an expression goes:

SELECT * FROM tickets
WHERE typesafe.main.is_true(body, 'Is this an angry complaint?') > 0.5;

See the shape before you spend anything

DESCRIBE binds the call โ€” validating the questions and computing the result columns โ€” without sending a request or costing a token:

DESCRIBE SELECT * FROM typesafe.main.ask('x', questions => {
    'team':   {'type': 'choice', 'instructions': 'Which team?',
               'criteria': {'shipping': 'Lost packages', 'billing': 'Invoices'}},
    'urgent': {'type': 'noul', 'instructions': 'Needs a reply today?'}});
-- team   STRUCT(choice VARCHAR, confidence DOUBLE, probabilities MAP(VARCHAR, DOUBLE))
-- urgent STRUCT(noul DOUBLE)
-- usage  STRUCT(model VARCHAR, input_tokens BIGINT, output_tokens BIGINT)

The functions

FunctionUse it for
ask()Any number of questions per row, any mix of types, structured state.
ask_dynamic()Questions that differ per row. Answers come back as JSON, not typed columns.
choice()One choice question: pick an option. Flat output columns.
noul()One noul question: yes/no, answered as a probability.
score()One score question: a position on an ordered scale.
is_true()A noul as a scalar, for WHERE / CASE / ORDER BY without a join.
models()Which models model => will accept. No arguments, no tokens.

For the five question table functions the first argument is the per-row input, so the same call works on a literal, on FROM t, f(t.x), and on LATERAL f(t.x) alike โ€” and ask_dynamic() makes its second argument a per-row input too, which is exactly what lets the questions vary. is_true() is an ordinary scalar expression. models() takes no input at all.

ask(state, questions => ...)

A System One request carries one state and a map of named questions that the API answers in parallel โ€” so five questions about a row cost the same single request as one.

State โ€” the positional argument

You passThe API receives
VARCHARa string
STRUCT, e.g. {'message': t.body, 'tier': t.tier}a JSON object
the row alias itself: ask(t, ...)a JSON object of every column of t
LIST / MAPa JSON array / object
a JSON column, with parse_json => truethe parsed object or array

TypeSafe recommends an object for most requests: descriptive field names tell the model how the parts relate. (A DuckDB JSON column reaches the worker as plain text, indistinguishable from VARCHAR, which is why parsing it is opt-in.) Anything else โ€” a bare INTEGER, a BLOB โ€” is rejected at bind with a suggestion. Nested decimals, dates and timestamps are converted to JSON numbers and ISO strings.

Questions โ€” questions =>

A struct keyed by question name; the name becomes the output column.

typecriteriaAnswer column
choicestruct or MAP of option โ†’ description (1โ€“255)STRUCT(choice VARCHAR, confidence DOUBLE, probabilities MAP(VARCHAR, DOUBLE))
nouloptional {'true': ..., 'false': ...}STRUCT(noul DOUBLE) โ€” near 1 is yes, near 0 is no, 0.5 is genuinely undecided
scoreordered list of 2โ€“10 levels, lowest firstSTRUCT(score DOUBLE, confidence DOUBLE, probabilities MAP(INTEGER, DOUBLE)), keyed by level (0 = first)

A criterion may be a plain string or a structured object โ€” {'what': ..., 'not_for': ..., 'examples': [...]} for a choice option, {'summary': ..., 'signals': [...]} for a score level. A trailing usage STRUCT(model, input_tokens, output_tokens) column reports what each row cost, so a question may not be named usage. A JSON string or a MAP is accepted in place of the struct literal.

Questions are validated at bind, before any row is sent, with errors that name the question at fault (ask(): score question 'severity' requires 'criteria': an ordered list of 2-10 level descriptions).

Why a struct, not a list? The three shapes differ โ€” a choice's criteria is a map, a score's an ordered list, a noul's optional โ€” so DuckDB cannot unify them as elements of one LIST, and a LIST of UNIONs does not type-check as a literal either (The member 'noul' is not present in target union). As fields of one struct each question keeps its own type, and its key is the column name for free.

Other named arguments: model => (default jev-latest), concurrency => (default 8, max 64), parse_json => (default false).

Row semantics

  • Strictly one output row per input row, so answers always pair with the row that produced them.
  • confidence is how concentrated the distribution was โ€” it is the field to filter on when routing the clear cases automatically. A noul has none, because a probability near 0.5 already says the model could not decide.
  • A NULL state makes no request and yields NULL answers. So does a structured state with no non-null content โ€” {'message': NULL, 'tier': NULL} is not NULL in SQL, but there is nothing in it to judge, and asking anyway would bill a request for a meaningless answer. (0, false and '' are content.)
  • Identical states within a batch are asked, and billed, once โ€” regardless of key order.
  • Errors raise; they never become NULL. 429 and 529 are retried with exponential backoff first.

ask_dynamic(state, questions [, model =>, concurrency =>])

The same request as ask(), with the questions moved from a named argument to a positional one. For a blended table function a positional argument is a per-row input column, so the questions become data: two rows in one scan may ask entirely different things.

Reach for ask() first. Its questions are fixed when the query is planned, which is what lets it hand back one typed STRUCT column per question. ask_dynamic() trades those typed columns away โ€” nothing about the result shape is knowable at plan time, so every answer arrives inside one JSON column you take apart yourself. It is the more awkward of the two and should not be anyone's default. Use it when the questions genuinely vary per row: a rules table joined to the rows it governs, a work queue where each item carries its own rubric, questions assembled by an application.

-- Each row carries the question it wants asked, and they are not the same kind of question.
SELECT t.id,
       a.answers->'dept'->>'choice'            AS dept,
       (a.answers->'urgent'->>'noul')::DOUBLE  AS urgent
FROM queue t, LATERAL typesafe.main.ask_dynamic(t.body, t.questions) a
ORDER BY t.id;
Argument
statepositional, per-rowThe content to evaluate โ€” same shapes ask() accepts, except that parse_json does not exist here.
questionspositional, per-rowThat row's questions, keyed by name. JSON text, a STRUCT or a MAP.
modelnamed VARCHARDefaults to jev-latest.
concurrencynamed INTEGERIn-flight requests per input batch. Default 8, max 64.

model and concurrency stay named because a blended function cannot take a positional bind-time constant โ€” DuckDB sweeps one into the input subquery, where it is indistinguishable from an input column.

Writing the questions

Form
JSON textEach row carries its own shape verbatim, reconciled against no other row. Prefer this when the questions really differ.
STRUCT / MAPWorks, and reads better when the questions vary only in wording โ€” but DuckDB unifies a column's type across rows.

The unification is the catch. A question only some rows ask is padded onto every row as NULL; that part is harmless, because those NULLs are dropped before the request (a row is never billed for, or answered with, a question it did not ask). But two rows that give one question name two different criteria shapes โ€” a choice's map on one row, a score's list on the next โ€” cannot be unified at all, and DuckDB rejects the query before this worker ever sees it. JSON text has neither constraint.

Output

Output columnType
answersVARCHARA JSON object keyed by question name. NULL when the state was NULL.
modelVARCHARThe model that answered.
input_tokens, output_tokensBIGINTUsage for that row's request.

Each value inside answers is the answer object for that question's type โ€” {"choice", "confidence", "probabilities"}, {"noul"}, or {"score", "confidence", "probabilities"}. A score's probabilities are keyed by level number as ask()'s are, but JSON keys are text, so they read back as "0", "1", โ€ฆ โ€” reachable with a.answers->'sev'->'probabilities'->>'0'. The cost columns stay real columns precisely because their shape never varies: reading what a query spent should not cost a JSON parse.

Row semantics

Identical to ask(), with two differences:

  • Questions are validated per row, not at bind, because they are data. A row carrying no questions is an error that names the row and quotes its content โ€” under a correlated LATERAL DuckDB hands over one row at a time, so an index alone would locate nothing. A row whose state is NULL is never asked anything, so its questions are not examined at all.
  • De-duplication is on the (state, questions) pair, not on the state alone. The same text asked two different things is two requests; the same text asked the same thing twice is one.

choice(state, instructions =>, criteria => [, model =>, concurrency =>])

The shorthand for a single choice question over a text column. It returns flat columns rather than a struct, and serves every call shape โ€”

SELECT * FROM typesafe.main.choice('I was charged twice', instructions => ..., criteria => ...);  -- literal
SELECT ... FROM t, typesafe.main.choice(t.body, ...) c;                                            -- implicit lateral
SELECT ... FROM t, LATERAL typesafe.main.choice(t.body, ...) c;                                    -- explicit lateral
Argument
statepositional VARCHARThe content to evaluate. A literal or a column.
instructionsnamed VARCHAR, requiredThe question.
criterianamed MAP(VARCHAR, VARCHAR), requiredOption name โ†’ when that option applies. 1โ€“255 options.
modelnamed VARCHARDefaults to jev-latest.
concurrencynamed INTEGERIn-flight requests per input batch. Default 8, max 64.
Output columnType
choiceVARCHARThe highest-probability option.
confidenceDOUBLE0โ€“1, how concentrated the distribution is.
probabilitiesMAP(VARCHAR, DOUBLE)Every option's probability, in criteria order.
modelVARCHARThe model that answered.
input_tokens, output_tokensBIGINTUsage for that row's request.

Behaviour worth knowing:

  • Strictly one output row per input row. A NULL state yields a row of NULLs and makes no request.
  • Identical states within a batch are asked (and billed) once.
  • Errors raise; they never become NULL. A failed classification that looked like a NULL input would be silently wrong. 429 and 529 are retried with exponential backoff (honouring Retry-After) first.
  • instructions and criteria are validated at bind, so a malformed question fails before any row is sent.

noul(state, instructions => [, criteria =>, model =>, concurrency =>])

The shorthand for a single yes/no question. A noul answer is one probability and nothing else โ€” there is no chosen option and no distribution, so there is no confidence column either: the number already carries its own certainty, and 0.5 is exactly what low confidence looks like.

Argument
statepositional VARCHARThe content to evaluate. A literal or a column.
instructionsnamed VARCHAR, requiredThe question, phrased so yes and no both make sense.
criterianamed MAP(VARCHAR, VARCHAR), optional'true' โ†’ what a yes looks like, 'false' โ†’ what a no looks like. Either alone is fine; no other key is accepted.
modelnamed VARCHARDefaults to jev-latest.
concurrencynamed INTEGERIn-flight requests per input batch. Default 8, max 64.
Output columnType
noulDOUBLEProbability the answer is yes: near 1 yes, near 0 no, 0.5 genuinely undecided.
modelVARCHARThe model that answered.
input_tokens, output_tokensBIGINTUsage for that row's request.

Compare it against a threshold you pick, and keep the rows near 0.5 for a human. Row semantics, error handling and billing are identical to choice(). Omitting criteria sends no criteria key at all, which is not the same request as sending an empty one.

score(state, instructions =>, criteria => [, model =>, concurrency =>])

The shorthand for a single ordered-scale question. Where a choice's criteria is a MAP of named options, a score's is an ordered VARCHAR[]: the position of a level is its meaning, and the answer is expressed in those positions.

Argument
statepositional VARCHARThe content to evaluate. A literal or a column.
instructionsnamed VARCHAR, requiredThe question.
criterianamed VARCHAR[], requiredThe rungs of the scale, lowest first. 2โ€“10 of them.
modelnamed VARCHARDefaults to jev-latest.
concurrencynamed INTEGERIn-flight requests per input batch. Default 8, max 64.
Output columnType
scoreDOUBLEProbability-weighted position: 0.0 is the first level, and a row read as halfway between the first two rungs scores 0.5.
confidenceDOUBLE0โ€“1, how concentrated the distribution is.
probabilitiesMAP(INTEGER, DOUBLE)Each level's probability, keyed by level number (0 = first).
modelVARCHARThe model that answered.
input_tokens, output_tokensBIGINTUsage for that row's request.

score is a position, not a level: round it to snap to a rung, or leave it alone to rank rows against each other. probabilities is keyed by number rather than by the level's text, so the key stays meaningful when two levels read similarly. A scale with fewer than two rungs is rejected at bind โ€” production accepts one and scores every row 0.0, which is a scale that cannot place anything.

is_true(state, instructions)

The same yes/no question as noul(), as a scalar returning just the probability:

SELECT * FROM tickets WHERE typesafe.main.is_true(body, 'Is this urgent?') > 0.8;

UPDATE tickets SET urgent = typesafe.main.is_true(body, 'Is this urgent?') > 0.8;

A noul answer is one number, so a scalar loses nothing by returning only that โ€” and one number drops straight into a WHERE, a CASE, an ORDER BY or an UPDATE ... SET, where the table function needs a LATERAL join or a scalar subquery around it. This is conciseness, not capability: noul() can already express every one of those, and tests/test_end_to_end.py pins that the two agree on the same row.

  • It returns a DOUBLE, not a BOOLEAN โ€” it is the probability that the answer is yes, so compare it against a threshold. The name says which question is being asked, not what comes back.
  • DuckDB scalars take no named arguments, so the signature is the whole surface: the default model, no criteria, no usage columns. noul() is where those live.
  • It still batches: one request per distinct non-null value in each chunk, issued concurrently โ€” the same accounting the table functions do, through the same code path.
  • NULL in, NULL out, with no request. Errors raise, so a failure cannot silently drop a row from a WHERE clause. The question is checked at bind.

models()

Every question table function takes model => and defaults it to jev-latest (model docs). Nothing else in the catalog says what else is allowed โ€” and TypeSafe publishes a preview line alongside the stable one, so the default is not the only answer.

SELECT name, description, release_date FROM typesafe.main.models ORDER BY name;
-- jev-latest   The latest iteration of TypeSafe's System One Model: Jev       2026-09-10 18:38:01+00
-- jev-preview  A preview version of `jev-latest`: should be better in most ways  2026-09-10 18:39:06+00
Output columnType
nameVARCHARThe model id, exactly as model => wants it. Primary key.
descriptionVARCHARTypeSafe's own description.
release_dateTIMESTAMP WITH TIME ZONEWhen it was published; NULL if the API did not say.

Readable as a table (typesafe.main.models) or as a function (typesafe.main.models()) โ€” same rows either way. A listing that takes no arguments simply reads better without the parentheses.

  • It judges nothing and bills no tokens, so it is safe to call for discovery.
  • Results are cacheable for 5 minutes, so repeated calls in one session cost one request.
  • A rejected key or an unreachable endpoint raises. Zero rows would read as "this account has no models", which is a different fact.
  • model => is fixed when the query is planned, so it takes a literal: paste a name from here into the call rather than joining this listing into it.

API key

The key is a DuckDB secret, so it never appears in query text or duckdb_databases(), and duckdb_secrets() shows it redacted:

CREATE SECRET (TYPE typesafe, api_key 'ts-...');
-- optionally: base_url '...' to target a non-production endpoint

With no secret, the worker falls back to TYPESAFE_API_KEY / TYPESAFE_BASE_URL in its environment โ€” the same names the official TypeSafe SDKs read. With neither, the query fails with a message saying how to fix it.

Development

uv sync --all-extras          # creates .venv; dependencies come from PyPI
uv run pytest                 # the full suite: hermetic, no network and no key needed
uv run ruff check . && uv run ruff format --check .
uv run mypy vgi_typesafe/     # strict

TYPESAFE_API_KEY=... uv run pytest -m live   # the only tests that call the real API

The suite runs against a bundled endpoint that speaks TypeSafe's wire format, so it needs neither a key nor a network. Start it by hand to point a DuckDB session at it:

uv run vgi-typesafe-mock --port 8787 --api-key test-key
CREATE SECRET (TYPE typesafe, api_key 'test-key', base_url 'http://127.0.0.1:8787');

Ruff, mypy and pydoclint settings are mirrored from vgi-python, so the fleet lints identically: 120-column lines, Google-style docstrings enforced on tests as well as the package, and mypy strict.

Where we are stricter than the API

Production is laxer than its own API reference in two places. We follow the reference, and record both here so the gap stays a decision rather than a surprise:

DocumentedProduction actuallyWe
score levels2โ€“10accepts 1 (and scores every row 0.0)reject at bind
noul instructionsrequiredoptional if criteria is givenrequire it

License

MIT โ€” see LICENSE. Worker ยฉ 2026 Query Farm LLC. Judgments are produced by TypeSafe's System One models and are subject to TypeSafe's terms of use.