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)

  1. Normalize the text (NFC, collapse whitespace, trim) and check its length.
  2. Run authorize(context). A denial ends here, before any model call.
  3. Code finds date and number phrases and computes their values.
  4. One provider call answers closed multiple-choice questions built from your schema.
  5. Every answer is checked against the options it was offered.
  6. Your entity resolver looks up a named record, within the user's scope.
  7. Returns ready, needs_clarification, unsupported, blocked or unavailable.

execute(filters, options)

  1. Treat filters as untrusted input and validate them strictly against the schema.
  2. Reject empty filters unless allowEmptyFilters is set.
  3. Run authorize(context) again.
  4. Re-check entity ids with verify, if you gave one.
  5. Call your executor(filters, context) with a validated copy.
  6. 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>
PropertyTypeDefaultMeaning
resourcestringrequiredPlural name used in questions and messages, such as "support tickets". Its words are never treated as entity names.
versionstring"1"Bump it when field meanings change. Reported as meta.schemaVersion.
fieldsFrequiredField 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.

BuilderFilter valueOperatorsWho decidesUnderstands
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
booleanFieldtrue, falseisThe 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, lteCode 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, ltCode 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)isThe 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.

FormExampleResult
Single daystoday
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, yearthis 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, yearpast 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, monthslast 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 dates2026-09-01{ gte: "2026-09-01", lt: "2026-09-02" }
Numeric dates, D/M/YYYY or M/D/YYYYon 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 yearseptember 2025{ gte: "2025-09-01", lt: "2025-10-01" }
Bare month, only after a prepositionin 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:

PrepositionExampleResult
sincesince last week{ gte: "2026-09-14" } (start of the period)
afterafter 2026-09-01{ gte: "2026-09-02" } (the day after the period ends)
beforebefore yesterday{ lt: "2026-09-22" }
until, tilltill 2026-09-10{ lt: "2026-09-11" } (the named day is included)
on, in, during, fromduring last month{ gte: "2026-08-01", lt: "2026-09-01" }
from or between X to, until, till, through or and Yfrom 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:

OperatorWords before the numberWords after the numberExampleResult
ltunder, below, less than, fewer than, lower than, cheaper than, smaller than, <under $500{ lt: 500 }, unit USD
lteat most, no more than, not more than, up to, maximum, maximum of, max, <=or less, or fewer, or below, or lower, and below, and underup to €200
20 or less
{ lte: 200 }, unit EUR
{ lte: 20 }
gtover, above, more than, greater than, higher than, larger than, exceeding, >over 2k
above 12.5
{ gt: 2000 }
{ gt: 12.5 }
gteat least, no less than, not less than, minimum, minimum of, min, >=+, or more, or above, or higher, and above, and up, or greaterat least 3 replies
100 or more
50+
{ gte: 3 }
{ gte: 100 }
{ gte: 50 }
eqexactly, equal to, equals, =, or no comparatorexactly 42
3 replies
{ eq: 42 }
{ eq: 3 }
gte + ltebetween X and Y (also to or - as the joiner), in either orderbetween 500 and 100{ gte: 100, lte: 500 }
FeatureRecognizedExampleResult
Currency symbols$ USD, EUR, £ GBP, INRunder £30{ lt: 30 }, unit GBP
Codes and words beforeusd, inr, eur, gbp, rs, rs.under INR 5,000{ lt: 5000 }, unit INR
Codes and words afterusd, inr, eur, gbp, dollar(s), rupee(s), euro(s), pound(s)under 40 euros{ lt: 40 }, unit EUR
Suffixesk (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 decimals1,000,000, Indian grouping, decimalsunder ₹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

OptionTypeDefaultWhat it does
schemaSearchSchema<F>requiredFrom defineSearch.
providerFilterProvider<Ctx>requiredAnswers the closed questions. jev(), withCache(...), mockProvider, keywordProvider, or your own.
executor(filters: Filters<F>, context: Ctx) => Promise<R> | RnoneYour 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> | booleanrequired unless allowUnauthenticated: trueRuns 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").
allowUnauthenticatedbooleanfalseSet to true only for public data anyone may search. It makes skipping authorize a written decision; false does not count as an opt-out.
timeZonestring"UTC"IANA time zone for date phrases. PrepareOptions.timeZone overrides it per call.
weekStartsOnnumber10 is Sunday, 1 is Monday. Used by "this week" and "last week".
minConfidencenumber0.6Minimum probability to accept an answer other than "not mentioned" without asking. Below it, the user gets a clarification.
timeoutMsnumber10000Total 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.
maxInputLengthnumber500Maximum request length in characters, after whitespace is collapsed and trimmed. A separate, fixed cap of 2048 UTF-8 bytes also applies.
allowEmptyFiltersbooleanfalseAllow execute({}), which browses everything within the caller's scope. Without it, empty filters are invalid.
onError(error: unknown, stage: "provider" | "resolver" | "authorize") => voidnoneCalled 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;
}
OptionDefaultMeaning
contextundefinedTrusted server context. Passed to authorize, the provider (for example to pick a per-tenant key) and your resolver. Never sent to a model.
nowthe current timeReference time for relative dates.
timeZoneconfig.timeZone, then "UTC"Time zone for this call.
signalnoneCombined 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:

  1. throws Error("createNaturalFilter: no executor configured") when there is no executor;
  2. validates with validateFilters and returns { status: "invalid", errors } on failure;
  3. returns invalid with ["no filters"] for an empty object, unless allowEmptyFilters is set;
  4. runs authorize(context) and returns { status: "blocked", reason: "unauthorized" } on denial, so a permission revoked after the preview blocks the search;
  5. calls verify(id, context) for the entity field if you gave one, returning invalid with ["<field>: not found"] when it does not return true;
  6. 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";
StatusMeaningWhat to do
readyValidated filters you can execute. The only status meant to run as it is.Show interpretation as chips, then call execute(result.filters).
needs_clarificationSomething needs the user's choice. filters holds what was understood.Show questions. See Answering a question.
unsupportedThe request is outside what this schema and v0.1 can express.Show message and offer your manual filters.
blockedStopped before any model call.Show message.
unavailableThe provider or the resolver failed. Nothing falls back to searching everything.Offer a retry when retryable is true.

Reason codes

StatusReasonWhen it happensMessage
blockedempty_inputThe text is empty or only whitespace, or not a string.Type what you are looking for.
blockedinput_too_longLonger than maxInputLength characters or 2048 UTF-8 bytes.Keep searches under maxInputLength characters.
blockedunauthorizedauthorize returned something other than true, or threw.You can't search here.
unsupportedtoo_complexMore 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.
unsupportedout_of_scopeThe 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.
unsupportedmultiple_valuesThe 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.
unsupportedcontradictoryDates 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.
unsupportedunit_mismatchThe number field has a unit and the request names a different currency. Sets field.label is in unit, not unit.
unsupportedno_filtersNothing 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.
unavailableprovider_errorThe provider threw. retryable comes from ProviderError.retryable, and is true for any other error.Search interpretation is unavailable.
unavailabletimeoutThe 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.)
unavailableinvalid_provider_outputSome question got no answer, or an answer that is not one of its offered labels. Not retryable.Search interpretation returned an invalid answer.
unavailableresolver_errorYour 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
}
KindWhenOptionsQuestion
choose_valueAn 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_entityYour resolver returned 2 to 20 candidates.One per candidate: { value: id, label, filters: { [field]: id } }.Which label did you mean?
choose_fieldThe 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_matchYour resolver returned no candidates.None.No label matched "phrase".
ambiguous_dateA 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.

ResultMeta

interface ResultMeta {
  schemaVersion: string;
  provider: string;
  model?: string;
  usage?: { inputTokens: number; outputTokens?: number };
  cached?: boolean;
}
FieldMeaning
schemaVersionThe schema's version.
providerThe provider's name, such as "jev".
modelThe model the provider's response reports, when it reports one.
usageToken usage from the provider's response. A cache hit reports { inputTokens: 0, outputTokens: 0 }.
cachedtrue 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:

InputError
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__ keyunknown 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.

IdAsked whenOption labels
intentAlways.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

OptionTypeDefaultWhat it does
apiKeystring | ((context: Ctx) => string | Promise<string>)the TYPESAFE_API_KEY environment variableA 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.
clientTypeSafeClientnoneA pre-built client (custom fetch, logging, proxies). When set, apiKey, baseURL, timeout, maxRetries and fetch are not used.
modelstring"jev-1.13.0"Model version, sent with every request and reported as the provider's model.
baseURLstringTYPESAFE_BASE_URL, then https://api.typesafe.aiAPI root.
timeoutnumber5000Timeout per attempt in ms. The core timeoutMs still caps the whole prepare.
maxRetriesnumber1Retries after the first attempt.
fetch(input: string, init?: RequestInit) => Promise<Response>global fetchCustom 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 }
);
OptionTypeDefaultWhat it does
storeCacheStorerequiredWhere answers live: memoryCache(), or your own { get, set } backed by Redis, KV and so on.
scope(context: Ctx) => stringone of scope or shared is requiredPartitions the cache, usually by tenant: (ctx) => ctx.tenantId. Must return a non-empty string.
sharedtrueone of scope or shared is requiredOne cache for everyone. Public data only. Kept apart from a tenant that happens to be named "shared".
ttlMsnumber600000 (10 minutes)How long an answer stays valid.
storeTimeoutMsnumber250Longest 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
OptionDefaultMeaning
maxEntries500Entries kept before the least recently used is evicted. Rounded down; must be a finite number of at least 1.
ttlMsno capCaps 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

LimitValueWhat happens
Fields per schema (LIMITS.maxFields)1 to 16defineSearch throws
Field nameletter first, then letters, digits or _; 64 characters at mostdefineSearch throws
Entity fields per schemaat most 1defineSearch throws
Values per enum (LIMITS.maxEnumValues)1 to 100enumField throws
Enum value lengthnot blank, 100 characters at mostenumField throws
Request length (maxInputLength)500 characters by defaultblocked, input_too_long
Request size2048 UTF-8 bytes, fixedblocked, input_too_long
Date phrases per request45 or more: unsupported, too_complex
Numbers per request45 or more: unsupported, too_complex
Relative window size ("last N days")1 to 999outside it, no date is found
Entity phrase candidates sent to the model60, each 1 to 3 wordsthe shortest phrases are kept
Entity candidates (LIMITS.maxEntityCandidates)20extra candidates are dropped
Entity candidate label200 characterscut
Entity id in filters1 to 256 charactersinvalid
Provider calls per prepare1, plus one resolver call when an entity is named
prepare time budget (timeoutMs)10000 ms by defaultunavailable, timeout
Accept a non-default answer (minConfidence)0.6 by defaultbelow it, a clarification
Ignore a date or number code found0.9, fixedbelow it, a choose_field clarification
Ask about a value rated plausible0.15, fixeda choose_value clarification
Jev request timeout and retries5000 ms per attempt, 1 retry
Cache TTL, store read timeout600000 ms, 250 ms
memoryCache entries500 by defaultleast 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.