jevfilter v0.1.0
API reference
Everything the jevfilter package exports, with the defaults and limits taken from the v0.1.0 source. For a guided introduction, start with the README.
Overview
npm install jevfilter @typesafe-ai/sdk
The package runs server-side on Node.js 20 or later and ships as ESM only. Its types need TypeScript 5.0 or later. It has two entry points:
jevfilter: the core, with no runtime dependencies.
jevfilter/jev: the Jev provider. It imports @typesafe-ai/sdk, an optional peer dependency pinned to ^0.6.0. You only need it if you use jev().
A search built with createNaturalFilter has two steps. prepare() reads text and never runs a search. execute() runs your search and never calls a model.
prepare(text, options)
- Normalize the text (NFC, collapse whitespace, trim) and check its length.
- Run
authorize(context). A denial ends here, before any model call.
- Code finds date and number phrases and computes their values.
- One provider call answers closed multiple-choice questions built from your schema.
- Every answer is checked against the options it was offered.
- Your entity resolver looks up a named record, within the user's scope.
- Returns
ready, needs_clarification, unsupported, blocked or unavailable.
execute(filters, options)
- Treat
filters as untrusted input and validate them strictly against the schema.
- Reject empty filters unless
allowEmptyFilters is set.
- Run
authorize(context) again.
- Re-check entity ids with
verify, if you gave one.
- Call your
executor(filters, context) with a validated copy.
- Returns
ok, invalid or blocked.
import { createNaturalFilter, defineSearch, enumField, dateField } from "jevfilter";
import { jev } from "jevfilter/jev";
const tickets = defineSearch({
resource: "tickets",
fields: {
status: enumField({ open: "still open, active", closed: "resolved, done" }),
createdAt: dateField({ label: "created" }),
},
});
const search = createNaturalFilter({
schema: tickets,
provider: jev(), // reads TYPESAFE_API_KEY
authorize: (session) => session.canSearchTickets,
executor: (filters, session) => searchTickets(filters, session),
});
const result = await search.prepare("open tickets from last week", { context: session });
if (result.status === "ready") {
const page = await search.execute(result.filters, { context: session });
}
Schema
A schema lists the fields your API can already filter on. It is the only source of options the model can choose from. The builders validate their input and throw a TypeError when it is wrong, so mistakes show up when the module loads.
defineSearch(schema)
function defineSearch<const F extends Fields>(schema: {
resource: string;
version?: string;
fields: F;
}): SearchSchema<F>
| Property | Type | Default | Meaning |
resource | string | required | Plural name used in questions and messages, such as "support tickets". Its words are never treated as entity names. |
version | string | "1" | Bump it when field meanings change. Reported as meta.schemaVersion. |
fields | F | required | Field name to field, built with the builders below. |
Throws when there are no fields, more than 16 fields, a field name that does not match /^[A-Za-z][A-Za-z0-9_]{0,63}$/, or more than one entity field. The returned schema and its fields object are frozen.
enumField(values, options?)
function enumField<const V extends string>(
values: readonly V[] | Readonly<Record<V, string | null>>,
options?: FieldBase,
): EnumField<V>
Pass an array of allowed values, or an object that maps each value to a description. The description is sent to the model, so it is where business synonyms go: { high: "urgent, critical, P1" } is the only reason "urgent" can mean high. Throws with no values, more than 100 values, or a value that is blank or longer than 100 characters.
Filter value: "open" or { not: "closed" }.
booleanField(options?)
function booleanField(options?: FieldBase): BooleanField
A yes/no flag. Filter value: true or false. Use description to say what "yes" means, for example "escalated to a manager".
numberField(options?)
function numberField(options?: FieldBase & { unit?: string }): NumberField
Filter value: a NumberRange such as { lt: 500 }. unit is a currency code or a unit label. When both the field and the request name a unit and they differ (compared case-insensitively), prepare returns unsupported with reason unit_mismatch. The parser only recognizes USD, EUR, GBP and INR, so a field with unit: "replies" accepts plain numbers and refuses currency amounts such as "$5". A request number with no unit is accepted for any field.
dateField(options?)
function dateField(options?: FieldBase): DateField
Filter value: a half-open DateRange of calendar dates, { gte: "2026-08-01", lt: "2026-09-01" }, in the request time zone.
entityField(options)
function entityField<Ctx = unknown>(
options: FieldBase & Pick<EntityField<Ctx>, "resolve" | "verify">,
): EntityField<Ctx>
resolve: (phrase: string, context: Ctx) => Promise<EntityCandidate[]> | EntityCandidate[];
verify?: (id: string, context: Ctx) => Promise<boolean> | boolean;
A named record, such as a customer or an owner. The model picks which phrase of the request names it. Your resolve function looks that phrase up within what context may see and returns { id, label } candidates:
- exactly one candidate binds, and its
id becomes the filter value;
- zero candidates give a
no_match clarification;
- two or more give a
choose_entity clarification listing them.
Candidates are deduplicated by id, labels are cut to 200 characters, and the list stops at 20 (LIMITS.maxEntityCandidates). A result that is not an array, or has an item without a non-empty string id and a string label, gives unavailable with reason resolver_error. Candidate labels and ids are never sent to the model.
verify is optional. When present, execute calls it for the entity id in the filters and treats anything other than true, or a throw, as "not found". Throws at build time when resolve is not a function.
LIMITS
const LIMITS = {
maxFields: 16,
maxEnumValues: 100,
maxEntityCandidates: 20,
} as const;
The full list of hard limits is in Limits.
Schema types
interface FieldBase {
label?: string; // shown in chips and questions; defaults to the field key
description?: string; // plain-language meaning, sent to the model
}
interface EnumField<V extends string = string> extends FieldBase {
kind: "enum"; values: Readonly<Record<V, string | null>>;
}
interface BooleanField extends FieldBase { kind: "boolean" }
interface NumberField extends FieldBase { kind: "number"; unit?: string }
interface DateField extends FieldBase { kind: "date" }
interface EntityField<Ctx = unknown> extends FieldBase {
kind: "entity";
resolve: (phrase: string, context: Ctx) => Promise<EntityCandidate[]> | EntityCandidate[];
verify?: (id: string, context: Ctx) => Promise<boolean> | boolean;
}
interface EntityCandidate {
id: string; // opaque handle your executor understands; ends up in filters
label: string; // shown to the user in a chooser; never sent to the model
}
type Field<Ctx = any> = EnumField<string> | BooleanField | NumberField | DateField | EntityField<Ctx>;
type Fields<Ctx = any> = Record<string, Field<Ctx>>;
interface SearchSchema<F extends Fields = Fields> {
resource: string; version: string; fields: F;
}
interface NumberRange { eq?: number; gt?: number; gte?: number; lt?: number; lte?: number }
interface DateRange { gte?: string; lt?: string } // YYYY-MM-DD, half-open
type FilterValue<F> = // enum: V | { not: V }
// boolean: boolean
// number: NumberRange
// date: DateRange
// entity: string (the id)
type Filters<F extends Fields> = { [K in keyof F]?: FilterValue<F[K]> };
Filters is the typed object your executor receives. Every key is optional, and every present bound of a NumberRange must hold.
Field types
Who decides a value depends on the field type. Code computes dates and numbers. The model only chooses among options that code built.
| Builder | Filter value | Operators | Who decides | Understands |
enumField | "high", { not: "closed" } | is, is not (one value) | The model picks one of your values, or says the field is not mentioned | "open tickets", "everything except closed", "urgent" when a description lists it |
booleanField | true, false | is | The model picks yes, no or not mentioned | "escalated tickets", wording from the field's description |
numberField | { lt: 500 }, { gte: 100, lte: 500 } | eq, gt, gte, lt, lte | Code parses the number and comparator. The model only picks the field | "under $500", "at least 3", "between 100 and 500", "over 2k" |
dateField | { gte: "2026-08-01", lt: "2026-09-01" } | gte, lt | Code does the calendar math in your time zone. The model only picks the field | "last week", "since 2026-09-01", "in august", "last 7 days" |
entityField | "cus_4" (your id) | is | The model picks a phrase from the request. Your resolver finds the records | "Sam's invoices", "tickets for Acme Corp" |
When two phrases land on the same date or number field, their ranges are intersected, so "over 100 and under 500" becomes { gt: 100, lt: 500 }. A range that can never match returns unsupported with reason contradictory.
Date phrases
parseDates finds these phrases and turns each into a half-open range of calendar dates. The outputs below are real, with now = 2026-09-23T10:00:00Z (a Wednesday), time zone UTC and weeks starting on Monday.
| Form | Example | Result |
| Single days | today yesterday | { gte: "2026-09-23", lt: "2026-09-24" }
{ gte: "2026-09-22", lt: "2026-09-23" } |
Calendar periods: this, last, previous + week, month, quarter, year | this week last week last month this quarter last year | { gte: "2026-09-21", lt: "2026-09-28" }
{ gte: "2026-09-14", lt: "2026-09-21" }
{ gte: "2026-08-01", lt: "2026-09-01" }
{ gte: "2026-07-01", lt: "2026-10-01" }
{ gte: "2025-01-01", lt: "2026-01-01" } |
Rolling windows ending today: past + week, month, quarter, year | past week past month | { gte: "2026-09-17", lt: "2026-09-24" }
{ gte: "2026-08-23", lt: "2026-09-24" } |
last or past + N (1 to 999) days, weeks, months | last 7 days past 2 weeks last 3 months | { gte: "2026-09-17", lt: "2026-09-24" }
{ gte: "2026-09-10", lt: "2026-09-24" }
{ gte: "2026-06-23", lt: "2026-09-24" } |
| ISO dates | 2026-09-01 | { gte: "2026-09-01", lt: "2026-09-02" } |
Numeric dates, D/M/YYYY or M/D/YYYY | on 23/09/2026 on 03/04/2026 | { gte: "2026-09-23", lt: "2026-09-24" } ambiguous: no range, and prepare asks with ambiguous_date |
| Month and day, with or without a year, ordinals, "of" | sep 14 14th of september Sept. 5th, 2025 sep 30 | { gte: "2026-09-14", lt: "2026-09-15" }
{ gte: "2026-09-14", lt: "2026-09-15" }
{ gte: "2025-09-05", lt: "2025-09-06" }
{ gte: "2025-09-30", lt: "2025-10-01" } |
| Month and year | september 2025 | { gte: "2025-09-01", lt: "2025-10-01" } |
| Bare month, only after a preposition | in august in december | { gte: "2026-08-01", lt: "2026-09-01" }
{ gte: "2025-12-01", lt: "2026-01-01" } |
Rules the parser applies:
- A numeric date is read as day/month when the first number is over 12, and as month/day when the second is. When both are 12 or less and differ, it is ambiguous. The same number twice (04/04/2026) is fine.
- Without a year, a month and day means the most recent one that is not in the future, and a bare month means the most recent month that has started. That is why "sep 30" and "in december" fall in 2025 above.
- "may" alone is ignored because it is usually a verb ("tickets that may be urgent" finds no date). "in may" works.
- "this week" and "last week" follow
weekStartsOn. With weekStartsOn: 0, "this week" is { gte: "2026-09-20", lt: "2026-09-27" }.
- "today" is computed in the request time zone with
todayIn. At 20:00 UTC on 23 September it is already 24 September in Asia/Kolkata.
- Invalid dates such as
2026-02-30 and backwards ranges are dropped.
A preposition in front of any of the forms above changes the range:
| Preposition | Example | Result |
since | since last week | { gte: "2026-09-14" } (start of the period) |
after | after 2026-09-01 | { gte: "2026-09-02" } (the day after the period ends) |
before | before yesterday | { lt: "2026-09-22" } |
until, till | till 2026-09-10 | { lt: "2026-09-11" } (the named day is included) |
on, in, during, from | during last month | { gte: "2026-08-01", lt: "2026-09-01" } |
from or between X to, until, till, through or and Y | from 2026-09-01 to 2026-09-10 between aug 1 and aug 15 | { gte: "2026-09-01", lt: "2026-09-11" }
{ gte: "2026-08-01", lt: "2026-08-16" } |
Not understood in v0.1 (each of these finds no date): tomorrow, next week and other future-relative phrases, 3 days ago, weekday names (last monday), quarter names (q3 2026), mid august, a bare month with no preposition (august), numbers written as words (last seven days), last 0 days, windows of 1000 or more, and times of day. A bare year (in 2025) is not a date: it is read as the number 2025.
Number phrases
parseNumbers reads a number with its comparator and currency. Numbers inside date phrases are skipped when you pass the date spans as exclude, as prepare does. Real outputs:
| Operator | Words before the number | Words after the number | Example | Result |
lt | under, below, less than, fewer than, lower than, cheaper than, smaller than, < | | under $500 | { lt: 500 }, unit USD |
lte | at most, no more than, not more than, up to, maximum, maximum of, max, <= | or less, or fewer, or below, or lower, and below, and under | up to €200 20 or less | { lte: 200 }, unit EUR
{ lte: 20 } |
gt | over, above, more than, greater than, higher than, larger than, exceeding, > | | over 2k above 12.5 | { gt: 2000 }
{ gt: 12.5 } |
gte | at least, no less than, not less than, minimum, minimum of, min, >= | +, or more, or above, or higher, and above, and up, or greater | at least 3 replies 100 or more 50+ | { gte: 3 }
{ gte: 100 }
{ gte: 50 } |
eq | exactly, equal to, equals, =, or no comparator | | exactly 42 3 replies | { eq: 42 }
{ eq: 3 } |
gte + lte | between X and Y (also to or - as the joiner), in either order | between 500 and 100 | { gte: 100, lte: 500 } |
| Feature | Recognized | Example | Result |
| Currency symbols | $ USD, € EUR, £ GBP, ₹ INR | under £30 | { lt: 30 }, unit GBP |
| Codes and words before | usd, inr, eur, gbp, rs, rs. | under INR 5,000 | { lt: 5000 }, unit INR |
| Codes and words after | usd, inr, eur, gbp, dollar(s), rupee(s), euro(s), pound(s) | under 40 euros | { lt: 40 }, unit EUR |
| Suffixes | k (1,000), m (1,000,000), lakh or lakhs (100,000), crore or crores (10,000,000) | over 2 lakh under 1 crore rupees | { gt: 200000 }
{ lt: 10000000 }, unit INR |
| Grouping and decimals | 1,000,000, Indian grouping, decimals | under ₹5,00,000 | { lt: 500000 }, unit INR |
Not understood in v0.1: negative numbers (-5 reads as 5), numbers written as words, the symbols ≤ and ≥ (the number is read as eq), ranges without "between" (100 to 200 gives two separate numbers), and units other than the four currencies. A % sign is ignored (under 10% is { lt: 10 }), and m always means million, never minutes. Numbers glued to letters, such as P1, are not read as numbers, and values beyond Number.MAX_SAFE_INTEGER are dropped.
Core
createNaturalFilter(config)
function createNaturalFilter<F extends Fields, Ctx = unknown, R = unknown>(
config: NaturalFilterConfig<F, Ctx, R>,
): NaturalFilter<F, Ctx, R>
interface NaturalFilter<F extends Fields, Ctx, R> {
readonly schema: SearchSchema<F>;
prepare(text: string, options?: PrepareOptions<Ctx>): Promise<NaturalFilterResult<F>>;
execute(filters: unknown, options?: { context?: Ctx }): Promise<ExecuteResult<F, R>>;
validate(filters: unknown): ValidationResult<F>;
}
Ctx is your trusted server context, usually the session. R is whatever your executor returns. Throws a TypeError when schema has no fields, when provider has no choose function, or when authorize is missing and allowUnauthenticated is not exactly true.
NaturalFilterConfig
| Option | Type | Default | What it does |
schema | SearchSchema<F> | required | From defineSearch. |
provider | FilterProvider<Ctx> | required | Answers the closed questions. jev(), withCache(...), mockProvider, keywordProvider, or your own. |
executor | (filters: Filters<F>, context: Ctx) => Promise<R> | R | none | Your read-only search. It gets validated filters and the trusted context. Apply the tenant and user scope from context as an outer AND, never from filters. Optional for prepare; execute throws Error without it. Errors it throws are not caught. |
authorize | (context: Ctx) => Promise<boolean> | boolean | required unless allowUnauthenticated: true | Runs before any model call in prepare and again in every execute. Only a return value of exactly true allows. false, any other value, or a throw denies (a throw is reported to onError with stage "authorize"). |
allowUnauthenticated | boolean | false | Set to true only for public data anyone may search. It makes skipping authorize a written decision; false does not count as an opt-out. |
timeZone | string | "UTC" | IANA time zone for date phrases. PrepareOptions.timeZone overrides it per call. |
weekStartsOn | number | 1 | 0 is Sunday, 1 is Monday. Used by "this week" and "last week". |
minConfidence | number | 0.6 | Minimum probability to accept an answer other than "not mentioned" without asking. Below it, the user gets a clarification. |
timeoutMs | number | 10000 | Total budget for one prepare, including the model call and the resolver. When it runs out the result is unavailable with reason timeout. It does not apply to execute. |
maxInputLength | number | 500 | Maximum request length in characters, after whitespace is collapsed and trimmed. A separate, fixed cap of 2048 UTF-8 bytes also applies. |
allowEmptyFilters | boolean | false | Allow execute({}), which browses everything within the caller's scope. Without it, empty filters are invalid. |
onError | (error: unknown, stage: "provider" | "resolver" | "authorize") => void | none | Called with provider, resolver and authorize errors so you can log them. Results never include error details. |
prepare(text, options?)
prepare(text: string, options?: PrepareOptions<Ctx>): Promise<NaturalFilterResult<F>>
interface PrepareOptions<Ctx> {
context?: Ctx;
now?: Date;
timeZone?: string;
signal?: AbortSignal;
}
| Option | Default | Meaning |
context | undefined | Trusted server context. Passed to authorize, the provider (for example to pick a per-tenant key) and your resolver. Never sent to a model. |
now | the current time | Reference time for relative dates. |
timeZone | config.timeZone, then "UTC" | Time zone for this call. |
signal | none | Combined with the timeoutMs deadline. Aborting ends the call with an unavailable result. |
Interprets the text and never calls the executor. It makes one provider call, plus one call to your resolver when the request names an entity. Checks run in this order: empty input, length, authorize, then the count of dates and numbers (more than 4 of either is too_complex). None of these reach the provider. prepare does not throw for bad input; every outcome is a result.
Two thresholds keep conditions from disappearing quietly. If code found a date or number, the model must be at least 90% sure it is not a filter before it is ignored; otherwise the user is asked. If the model says an enum or boolean field is not mentioned but gives some value a probability of 0.15 or more, the user is asked too.
execute(filters, options?)
execute(filters: unknown, options?: { context?: Ctx }): Promise<ExecuteResult<F, R>>
Accepts filters from prepare, from an answered clarification, or from manual edits in your UI, and treats all of them as untrusted. In order, it:
- throws
Error("createNaturalFilter: no executor configured") when there is no executor;
- validates with
validateFilters and returns { status: "invalid", errors } on failure;
- returns
invalid with ["no filters"] for an empty object, unless allowEmptyFilters is set;
- runs
authorize(context) and returns { status: "blocked", reason: "unauthorized" } on denial, so a permission revoked after the preview blocks the search;
- calls
verify(id, context) for the entity field if you gave one, returning invalid with ["<field>: not found"] when it does not return true;
- calls your executor with the validated copy and returns
{ status: "ok", filters, results }.
execute never calls a model, so manual filter edits add no tokens and no model latency.
validate(filters)
validate(filters: unknown): ValidationResult<F>
Strict validation only, against this search's schema. The same as validateFilters(schema, filters).
Results
NaturalFilterResult
type NaturalFilterResult<F extends Fields = Fields> =
| { status: "ready"; filters: Filters<F>; interpretation: FilterChip[]; meta: ResultMeta }
| {
status: "needs_clarification";
filters: Filters<F>; // understood so far; not executable yet
interpretation: FilterChip[];
questions: Clarification<F>[];
meta: ResultMeta;
}
| { status: "unsupported"; reason: UnsupportedReason; message: string; field?: string; meta?: ResultMeta }
| { status: "blocked"; reason: "unauthorized" | "empty_input" | "input_too_long"; message: string }
| {
status: "unavailable";
reason: "provider_error" | "timeout" | "invalid_provider_output" | "resolver_error";
retryable: boolean;
message: string;
};
type UnsupportedReason =
| "no_filters" | "out_of_scope" | "multiple_values"
| "contradictory" | "unit_mismatch" | "too_complex";
| Status | Meaning | What to do |
ready | Validated filters you can execute. The only status meant to run as it is. | Show interpretation as chips, then call execute(result.filters). |
needs_clarification | Something needs the user's choice. filters holds what was understood. | Show questions. See Answering a question. |
unsupported | The request is outside what this schema and v0.1 can express. | Show message and offer your manual filters. |
blocked | Stopped before any model call. | Show message. |
unavailable | The provider or the resolver failed. Nothing falls back to searching everything. | Offer a retry when retryable is true. |
Reason codes
| Status | Reason | When it happens | Message |
blocked | empty_input | The text is empty or only whitespace, or not a string. | Type what you are looking for. |
blocked | input_too_long | Longer than maxInputLength characters or 2048 UTF-8 bytes. | Keep searches under maxInputLength characters. |
blocked | unauthorized | authorize returned something other than true, or threw. | You can't search here. |
unsupported | too_complex | More than 4 date phrases or more than 4 numbers. No model call is made and there is no meta. | That request has too many dates or numbers. Try fewer conditions. |
unsupported | out_of_scope | The model says the request asks for something other than a filter: other records, other tenants, permission changes, actions, counting or sorting, instructions to the system, or conditions no field can express. | This search can only filter resource by its listed fields. |
unsupported | multiple_values | The request allows or excludes more than one value of an enum field ("open or pending"). Sets field. | Pick one label at a time for now. |
unsupported | contradictory | Dates or numbers on one field can never all hold ("over 500 and under 100"). Sets field. Also returned without field if the final filters fail validation. | Those dates (or limits) for label can't all be true. |
unsupported | unit_mismatch | The number field has a unit and the request names a different currency. Sets field. | label is in unit, not unit. |
unsupported | no_filters | Nothing to filter on and nothing to ask, as in "tickets". JevFilter does not invent a filter. | Add a condition, for example a field or field. |
unavailable | provider_error | The provider threw. retryable comes from ProviderError.retryable, and is true for any other error. | Search interpretation is unavailable. |
unavailable | timeout | The timeoutMs budget ran out, or the provider or resolver threw an error named TimeoutError or APITimeoutError. Always retryable. | Search interpretation timed out. (Resolver: Couldn't look up label.) |
unavailable | invalid_provider_output | Some question got no answer, or an answer that is not one of its offered labels. Not retryable. | Search interpretation returned an invalid answer. |
unavailable | resolver_error | Your resolve threw (retryable) or returned an invalid shape (not retryable). | Couldn't look up label. |
Clarification
interface Clarification<F extends Fields = Fields> {
kind: "choose_value" | "choose_entity" | "choose_field" | "no_match" | "ambiguous_date";
field?: string;
question: string; // suggested text to show
phrase?: string; // the part of the request that needs clarifying
options: ClarificationOption<F>[]; // empty for no_match and ambiguous_date
}
interface ClarificationOption<F extends Fields = Fields> {
value: string; // enum value, entity id, or field name, depending on kind
label: string;
filters: Filters<F>; // merge into result.filters when picked
}
| Kind | When | Options | Question |
choose_value | An enum or boolean field: the model said "unclear", its confidence was below minConfidence, or it said "not mentioned" while rating a value at 0.15 or more. | Every allowed value (for booleans, "true" and "false"), plus value: "any" labelled "Any label" with filters: {}. | Which label did you mean? |
choose_entity | Your resolver returned 2 to 20 candidates. | One per candidate: { value: id, label, filters: { [field]: id } }. | Which label did you mean? |
choose_field | The model is not sure which date or number field a parsed phrase belongs to, or is less than 90% sure it is not a filter. Sets phrase, not field. | One per field of that kind, with the parsed range, plus value: "none" labelled "Don't filter by this" with filters: {}. | Which field should "phrase" apply to? |
no_match | Your resolver returned no candidates. | None. | No label matched "phrase". |
ambiguous_date | A numeric date such as 03/04/2026 could be either day/month or month/day. | None. | "phrase" could mean two dates. Write it as YYYY-MM-DD. |
Answering a question
When the user picks an option, merge its filters into result.filters and call execute. With several questions, merge the chosen option of each. For no_match and ambiguous_date there is nothing to pick: ask the user to rephrase, or open your manual filters.
if (result.status === "needs_clarification") {
const q = result.questions[0];
const picked = q.options.find((o) => o.value === userChoice);
const filters = { ...result.filters, ...picked.filters };
const page = await search.execute(filters, { context: session });
}
The "Any" and "Don't filter by this" options add nothing. If nothing else was understood, the merged object is empty and execute returns invalid unless allowEmptyFilters is set.
FilterChip
interface FilterChip {
field: string;
text: string; // human-readable
source?: string; // the part of the request it came from, when there is one
}
Chip text uses the field's label, or its key:
- enum:
priority is high, status is not closed
- boolean:
escalated: yes
- date:
created from 2026-09-14 to before 2026-09-21, created on or after 2026-09-01, created before 2026-09-22
- number:
amount < 500 USD, with =, >, ≥, <, ≤ joined by "and"
- entity:
customer is Sam Wilson (the candidate's label)
Date, number and entity chips carry source.
interface ResultMeta {
schemaVersion: string;
provider: string;
model?: string;
usage?: { inputTokens: number; outputTokens?: number };
cached?: boolean;
}
| Field | Meaning |
schemaVersion | The schema's version. |
provider | The provider's name, such as "jev". |
model | The model the provider's response reports, when it reports one. |
usage | Token usage from the provider's response. A cache hit reports { inputTokens: 0, outputTokens: 0 }. |
cached | true when the answers came from withCache. Absent otherwise. |
ready and needs_clarification always carry meta. unsupported carries it except for too_complex. blocked and unavailable never do.
ExecuteResult
type ExecuteResult<F extends Fields, R> =
| { status: "ok"; filters: Filters<F>; results: R }
| { status: "invalid"; errors: string[] }
| { status: "blocked"; reason: "unauthorized"; message: string };
ok with an empty result set is still ok. The filters are never broadened to find something.
Validation
validateFilters(schema, input)
function validateFilters<F extends Fields>(
schema: SearchSchema<F>,
input: unknown,
): ValidationResult<F>
type ValidationResult<F extends Fields> =
| { ok: true; filters: Filters<F> }
| { ok: false; errors: string[] };
On success, filters is a fresh copy that holds only validated keys. It rejects:
| Input | Error |
Anything but a plain object (arrays, null, class instances) | filters must be a plain object |
A key that is not a schema field, including workspaceId or an own __proto__ key | unknown field: "…" |
Enum: a value outside the list, or an object other than { not: value } with an allowed value | <field>: must be one of the allowed values, or { not: value } |
Boolean: anything but true or false | <field>: must be true or false |
| Number: not a plain object, or empty | <field>: must be a range like { lt: 500 } |
Number: an operator other than eq, gt, gte, lt, lte, or a value that is not a finite number (NaN, Infinity, strings) | <field>: allowed operators are eq, gt, gte, lt, lte with finite numbers |
Number: bounds no number can satisfy, such as { lt: 5, gt: 10 } | <field>: range can never match |
| Date: not a plain object, or empty | <field>: must be a range like { gte: "2026-09-01", lt: "2026-10-01" } |
Date: an operator other than gte and lt, or a value that is not a real YYYY-MM-DD date (2026-02-30 fails) | <field>: allowed operators are gte and lt with YYYY-MM-DD dates |
Date: gte on or after lt | <field>: range can never match |
| Entity: not a string, empty, or longer than 256 characters | <field>: must be a non-empty id string |
All errors are collected, not just the first. Validation does not check that an entity id exists; that is what verify is for.
Parsers
The deterministic parsers that prepare uses, exported for tests and for your own UI. The phrases they accept are listed under Date phrases and Number phrases.
parseDates(text, opts)
function parseDates(
text: string,
opts: { now: Date; timeZone: string; weekStartsOn?: number }, // weekStartsOn default 1
): DateCandidate[]
interface Span { start: number; end: number; text: string }
interface DateCandidate extends Span {
range?: DateRange; // undefined when the phrase is ambiguous (03/04/2026)
}
parseDates("tickets from last week", { now: new Date("2026-09-23T10:00:00Z"), timeZone: "UTC" })
// [{ start: 8, end: 22, text: "from last week", range: { gte: "2026-09-14", lt: "2026-09-21" } }]
text is the exact source text, including the preposition.
parseNumbers(text, exclude?)
function parseNumbers(text: string, exclude?: Span[]): NumberCandidate[]
interface NumberCandidate extends Span {
range: NumberRange;
unit?: string; // upper-case currency code when the text named one
}
const text = "since 2026-09-01 under $50";
parseNumbers(text, parseDates(text, { now, timeZone: "UTC" }))
// [{ start: 17, end: 26, text: "under $50", range: { lt: 50 }, unit: "USD" }]
Numbers that overlap an exclude span are skipped. Pass the date candidates so the digits of a date are not read as numbers.
todayIn(now, timeZone)
function todayIn(now: Date, timeZone: string): string // "YYYY-MM-DD"
todayIn(new Date("2026-09-23T20:00:00Z"), "Asia/Kolkata") // "2026-09-24"
Providers
A provider answers closed multiple-choice questions. It never returns a value that becomes a filter directly: core checks every answer against the options it asked, and code computes every date and number.
FilterProvider
interface FilterProvider<Ctx = unknown> {
readonly name: string;
readonly model?: string; // part of the cache key
choose(request: ProviderRequest, options: ProviderCallOptions<Ctx>): Promise<ProviderResponse>;
}
interface ProviderRequest {
state: { search_request: string }; // the untrusted user text, kept apart from instructions
questions: Record<string, ChoiceSpec>; // question id → question
}
interface ChoiceSpec {
instructions: string; // trusted, compiled from the schema
options: Record<string, string | null>; // option label → description
}
interface ProviderCallOptions<Ctx = unknown> {
context: Ctx; // trusted server context; never send it to a model
signal: AbortSignal; // aborts at the prepare() deadline or with the caller's signal
}
interface ProviderResponse {
answers: Record<string, ProviderAnswer>;
model?: string;
usage?: { inputTokens: number; outputTokens?: number };
cached?: boolean;
}
interface ProviderAnswer {
choice: string; // must be one of the question's option labels
probabilities: Record<string, number>; // label → 0..1; include at least the chosen label
}
The contract:
- Answer every question id with one of its option labels. A missing answer or an unknown label makes the whole result
unavailable with reason invalid_provider_output. Extra answers are ignored.
- Report a probability for at least the chosen label. When it is missing, or not a number from 0 to 1, core treats the answer as unknown confidence (0). Answers other than "not mentioned" then become clarification questions instead of being accepted.
- Set
model on the provider so a model upgrade changes the cache key.
- Throw
ProviderError to say whether a retry could help.
import type { FilterProvider } from "jevfilter";
const myProvider: FilterProvider = {
name: "my-llm",
model: "my-llm-2026-09",
async choose({ state, questions }, { signal }) {
// questions: { [id]: { instructions, options: { [label]: description } } }
return { answers: { /* [id]: { choice: label, probabilities: { [label]: p } } */ } };
},
};
Question ids and option labels
Ids are stable, so tests and custom providers can rely on them.
| Id | Asked when | Option labels |
intent | Always. | filter, other |
field_<name> | For every enum and boolean field. | Enum: unspecified, is "<value>" and is not "<value>" for each value, several values, unclear. Boolean: unspecified, yes, no, unclear. |
date_<i> | For each date phrase found, when the schema has a date field. i counts from 0. | The name of each date field, and none. |
number_<i> | For each number found, when the schema has a number field. | The name of each number field, and none. |
entity_<name> | When the schema has an entity field and the request has candidate phrases. | none, phrase 1, phrase 2 and so on. Each description is a phrase of 1 to 3 words from the request, up to 60 of them. |
ProviderError
class ProviderError extends Error {
readonly retryable: boolean;
constructor(message: string, options: { retryable: boolean; cause?: unknown });
}
Its name is "ProviderError". prepare copies retryable into the unavailable result. Any other error counts as retryable, and an error named TimeoutError or APITimeoutError becomes reason timeout. The message goes to onError only.
mockProvider(answer, name?)
function mockProvider(
answer: (q: { id: string; question: ChoiceSpec; text: string }) => MockAnswer | undefined,
name?: string, // default "mock"
): FilterProvider<any>
type MockAnswer = string | { choice: string; probability?: number };
An offline provider for tests. Your function answers each question with an option label. undefined picks the first option, and the probability defaults to 1. It makes no network calls and reports no model or usage.
mockProvider(({ id }) => ({ intent: "filter", field_status: 'is "open"' })[id])
keywordProvider()
function keywordProvider(): FilterProvider<any> // name "keyword-baseline"
A naive keyword baseline built on mockProvider. It is not a language model. Use it to try the library without a key and as the baseline in evals. It matches enum values and short comma-separated synonyms from their descriptions, treats "not", "except", "excluding", "without" or "non" before a value as an exclusion, gives each date and number to the first field of its kind, and picks a capitalized phrase for an entity. Words such as "ignore", "tenant", "admin", "delete", "permission", "count" or "how many" make it answer other to the intent question.
Jev provider (jevfilter/jev)
jev(options?)
import { jev, DEFAULT_JEV_MODEL } from "jevfilter/jev";
function jev<Ctx = unknown>(options?: JevOptions<Ctx>): FilterProvider<Ctx> // name "jev"
Needs the @typesafe-ai/sdk peer dependency. JevFilter never ships a key or sends requests through a key of its own.
JevOptions
| Option | Type | Default | What it does |
apiKey | string | ((context: Ctx) => string | Promise<string>) | the TYPESAFE_API_KEY environment variable | A string (or the environment variable) builds one client on first use and reuses it. A function runs on every prepare with the trusted context, so each tenant can use its own key. The result is trimmed, a fresh client is built for that call, and no key is cached. An empty or non-string result fails the call with a non-retryable ProviderError, without contacting the API. |
client | TypeSafeClient | none | A pre-built client (custom fetch, logging, proxies). When set, apiKey, baseURL, timeout, maxRetries and fetch are not used. |
model | string | "jev-1.13.0" | Model version, sent with every request and reported as the provider's model. |
baseURL | string | TYPESAFE_BASE_URL, then https://api.typesafe.ai | API root. |
timeout | number | 5000 | Timeout per attempt in ms. The core timeoutMs still caps the whole prepare. |
maxRetries | number | 1 | Retries after the first attempt. |
fetch | (input: string, init?: RequestInit) => Promise<Response> | global fetch | Custom fetch, for proxies and tests. |
jev() // TYPESAFE_API_KEY from the environment
jev({ apiKey: process.env.JEV_API_KEY }) // one key for your app
jev({ apiKey: (session) => session.workspace.jevKey }) // each customer brings their own key
jev({ client: new TypeSafeClient({ /* … */ }) }) // full control
Clients built by jev() have SDK logging turned off, because request bodies contain the user's search text. Errors are mapped like this:
- SDK timeout: reason
timeout, retryable.
- Rate limit, connection error or HTTP 500 and above:
provider_error, retryable.
- Other API errors, such as HTTP 401:
provider_error, not retryable.
- No key configured:
provider_error, not retryable.
Messages passed to onError are sanitized (for example Jev request failed (HTTP 401)) and never contain keys, headers or request bodies.
DEFAULT_JEV_MODEL
const DEFAULT_JEV_MODEL = "jev-1.13.0";
The pinned default model. Upgrading it changes interpretations, so rerun your evals before you do.
Cache
withCache(provider, options)
function withCache<Ctx>(provider: FilterProvider<Ctx>, options: CacheOptions<Ctx>): FilterProvider<Ctx>
type CacheOptions<Ctx> = {
store: CacheStore;
ttlMs?: number;
storeTimeoutMs?: number;
} & (
| { scope: (context: Ctx) => string; shared?: never }
| { shared: true; scope?: never }
);
| Option | Type | Default | What it does |
store | CacheStore | required | Where answers live: memoryCache(), or your own { get, set } backed by Redis, KV and so on. |
scope | (context: Ctx) => string | one of scope or shared is required | Partitions the cache, usually by tenant: (ctx) => ctx.tenantId. Must return a non-empty string. |
shared | true | one of scope or shared is required | One cache for everyone. Public data only. Kept apart from a tenant that happens to be named "shared". |
ttlMs | number | 600000 (10 minutes) | How long an answer stays valid. |
storeTimeoutMs | number | 250 | Longest a store read may take before it counts as a miss. |
Only the provider's answers are cached. authorize, answer validation, entity resolution and your executor run on every search, and dates such as "last week" are recomputed from today, so a hit cannot skip security or go stale on the calendar. The wrapped provider keeps the inner provider's name and model.
The key is a SHA-256 hash of the provider's name and model, the scope (or shared), the search text, and the questions compiled from your schema. Editing a field's description or values changes the questions, so it misses the cache.
Failure rules:
- Building throws a
TypeError without a store that has get and set, without scope or shared: true, or when ttlMs or storeTimeoutMs is not a finite number of at least 1.
- If
scope(context) returns anything but a non-empty string, the search fails (unavailable, provider_error) instead of sharing a cache.
- A store read that throws, or takes longer than
storeTimeoutMs, counts as a miss. Writes run in the background, and a failing write is ignored.
- Only complete responses are stored, with a valid option for every question. Errors and invalid answers are never cached. What is stored is the answers and the model.
- Identical concurrent searches share one provider call. If that call fails, is abandoned or returns an incomplete answer, a waiting caller makes the call itself instead of inheriting the failure. After 3 such rounds it calls the provider directly.
- A hit returns
cached: true and zero usage, which reaches you as meta.cached.
With per-tenant keys, a cached answer inside one scope can be served to a user whose search would have been paid with a different key. If each tenant brings its own key, scope by tenant.
import { memoryCache, withCache } from "jevfilter";
import { jev } from "jevfilter/jev";
const provider = withCache(jev(), {
store: memoryCache({ maxEntries: 1000 }),
scope: (session) => session.tenantId,
ttlMs: 10 * 60_000,
});
memoryCache(options?)
function memoryCache(options?: { maxEntries?: number; ttlMs?: number }): CacheStore
| Option | Default | Meaning |
maxEntries | 500 | Entries kept before the least recently used is evicted. Rounded down; must be a finite number of at least 1. |
ttlMs | no cap | Caps how long any entry may live, whatever withCache asks for. Must be a finite number of at least 1. |
An in-process LRU cache, good for a single server or a Worker isolate. Reads and writes copy the value with structuredClone, so a caller that mutates an answer cannot change the cache. A write with a TTL of zero, a negative TTL or NaN is not stored. Invalid options throw a TypeError.
CacheStore
interface CacheStore {
get(key: string): Promise<ProviderResponse | undefined> | ProviderResponse | undefined;
set(key: string, value: ProviderResponse, ttlMs: number): Promise<void> | void;
}
Keys are 64-character hex strings. Return undefined for a miss.
Limits
| Limit | Value | What happens |
Fields per schema (LIMITS.maxFields) | 1 to 16 | defineSearch throws |
| Field name | letter first, then letters, digits or _; 64 characters at most | defineSearch throws |
| Entity fields per schema | at most 1 | defineSearch throws |
Values per enum (LIMITS.maxEnumValues) | 1 to 100 | enumField throws |
| Enum value length | not blank, 100 characters at most | enumField throws |
Request length (maxInputLength) | 500 characters by default | blocked, input_too_long |
| Request size | 2048 UTF-8 bytes, fixed | blocked, input_too_long |
| Date phrases per request | 4 | 5 or more: unsupported, too_complex |
| Numbers per request | 4 | 5 or more: unsupported, too_complex |
| Relative window size ("last N days") | 1 to 999 | outside it, no date is found |
| Entity phrase candidates sent to the model | 60, each 1 to 3 words | the shortest phrases are kept |
Entity candidates (LIMITS.maxEntityCandidates) | 20 | extra candidates are dropped |
| Entity candidate label | 200 characters | cut |
| Entity id in filters | 1 to 256 characters | invalid |
Provider calls per prepare | 1, plus one resolver call when an entity is named | |
prepare time budget (timeoutMs) | 10000 ms by default | unavailable, timeout |
Accept a non-default answer (minConfidence) | 0.6 by default | below it, a clarification |
| Ignore a date or number code found | 0.9, fixed | below it, a choose_field clarification |
| Ask about a value rated plausible | 0.15, fixed | a choose_value clarification |
| Jev request timeout and retries | 5000 ms per attempt, 1 retry | |
| Cache TTL, store read timeout | 600000 ms, 250 ms | |
memoryCache entries | 500 by default | least recently used evicted |
What v0.1 can and can't do
Possible
- Conditions on several fields, combined with AND ("urgent billing tickets from last week").
- One value per enum field, or excluding one value (
{ not: "closed" }).
- Boolean flags.
- Number comparisons and ranges, including "between" and two bounds on one field.
- Relative and absolute dates, in your time zone, with a configurable first day of the week.
- Finding a named record through your resolver, with a chooser when several match.
- Clarification questions instead of guesses.
- Currency checks for USD, EUR, GBP and INR.
- Manual filter edits that skip the model and go through the same validation.
- A Jev key per app or per tenant.
- Caching model answers, per scope.
- Custom providers, and offline providers for tests.
Not possible
- OR across fields.
- "A or B" on one field, or excluding more than one value (
multiple_values).
- Nested or grouped conditions.
- Sorting, ranking or limits ("latest", "biggest", "top 10").
- Counting or aggregation.
- Free-text search inside content ("the doc where we discussed AWS costs").
- Languages other than English: the date and number parsers are English only.
- More than one entity field, or more than one named record per request.
- More than 4 dates or 4 numbers in one request.
- Future-relative dates ("tomorrow", "next week"), weekday names and quarter names.
- Unit checks for anything but the four currencies.
- Null handling for
{ not: X }; that is up to your executor.
Also, don't name the resource after one of its values, such as "support tickets" with a support category. And the model can still misread a request it is allowed to make, so show the chips and let users edit them.
Security
The library enforces
authorize is required unless you declare the data public, and runs before any model call and again before every execute.
- The model only picks among closed options. Code computes dates and numbers, and your resolver finds records.
- Every answer is checked against the options offered. A bad answer gives
unavailable and never a broader search.
execute validates filters from the client strictly and passes your executor a fresh copy.
- No SQL, ORM code or other executable text is produced.
- The search text travels in
state, apart from the instructions. Questions quote only the date, number and name phrases that code found in it.
- Results never include error details, and the Jev provider keeps keys out of logs, results and errors.
- The cache needs an explicit scope.
Your app must
- Write
authorize correctly. JevFilter can't fix broken authorization.
- Apply tenant and user scope inside the executor, from
context, as an outer AND. Never take scope from filters.
- Keep the executor read-only.
- Scope the entity resolver to what the user may see, and add
verify for ids.
- Keep API keys on the server and store customer keys securely.
- Rate-limit searches as you would any paid API call.
What the model receives
The search text, plus instructions and options compiled from your schema: the resource name, field names and labels, field kinds, descriptions, enum values and their descriptions, and the date, number and name phrases found in the search text. If your values or descriptions are sensitive, treat them like the search text.
It never receives your records, entity candidate labels or ids, the context, database credentials, or query code.