# Jevex A small Bijection component that uses Jev to build a semantic index over your tables. - Package: @bijectionhq/jevex - Category: AI & Agents - Status: Preview - License: MIT - Bijection source (repository access required): https://github.com/bijectionhq/jevex/tree/fc6cb6fa66579d55968bfede6ac4d3e828af3e40 - Upstream: https://github.com/mbilskilets/jevex/tree/a9ffe44bd52f103ff8e5aaf74b273ca69335d1ab - Documentation: https://docs.bijection.com/components/using - Directory: https://bijection.com/components/jevex ## Get the source Bijection repository access is required. Request access at https://bijection.com/contact. ```sh git clone https://github.com/bijectionhq/jevex.git cd jevex git checkout fc6cb6fa66579d55968bfede6ac4d3e828af3e40 ``` ## Before using this component Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute. ## Upstream package documentation Recorded 2026-10-23. This describes @mbilskilets/jevex; check the Bijection adaptation separately. Jevex builds a semantic index over Convex tables by sending rows to the Jev classification API when data changes and storing typed answers in an indexed table. Queries filter and sort by those answers using normal indexed reads, with no model calls at query time. It handles batching, caching, stale state, and reactivity automatically through a Convex trigger on your mutation. ### Use cases - Triaging support tickets by classifying them as bugs, feature requests, or billing issues and sorting by churn risk without scanning the table on every read - Moderating user-generated content by scoring posts for spam or abuse and routing low-confidence rows to a human review queue - Alerting on high-value signals like a paying customer showing churn intent by hooking into the onJudged callback to schedule a Slack notification - Ranking inbound leads or job applicants and keeping the ranking current automatically when a row is edited - Filtering marketplace listings or app reviews by a semantic label such as crash mentions without building a separate data pipeline ### How it works You define an index with `jevex.index(table, { state, questions, onJudged })` where `state` picks which fields Jev sees and `questions` maps names to `noul`, `choice`, or `score` descriptors. You register a trigger on your table using `convex-helpers` Triggers so every insert, update, and delete flows through the component in the same transaction as the write. The write path is synchronous and cheap. The trigger hashes the questions together with the `state` fields. If nothing changed, it returns early. If the same content was seen before, the cached answer is applied immediately in the same transaction. New content is queued for the Jev API without blocking the mutation. A dispatcher batches queued rows up to 20 per request with a 100ms debounce, and a workpool keeps up to 16 requests in flight concurrently. Answers land in a dedicated table indexed by `(index, question, label, value)`, so `index.top(ctx, 'churn', { min: 0.8 })` is a bounded index range scan with no JavaScript post-filtering. The component tracks four judgment states: `pending`, `judged`, `stale`, and `failed`. A stale row shows old answers while new ones are in flight. A cron re-queues rows whose batch disappeared, such as when a local backend restarts mid-batch. Jev can be reached through TypeSafe, Vercel AI Gateway, OpenRouter, or the Convex AI Gateway by setting the corresponding environment variable in `convex.config.ts`. ### Upstream README # jevex A small Convex component that uses [Jev](https://docs.typesafe.ai) to build a semantic index over your tables. You write questions about a table in plain English: "is this a bug report?", "is this customer about to leave?". jevex asks Jev when a row changes and stores the answers in an indexed table. Your queries then filter and sort by those answers the same way they would by a number column. ```ts const leaving = await feedbackJudge.top(ctx, "churn", { min: 0.8 }); ``` That line never calls a model. It reads an index range, so it's as fast as any other Convex query and it updates live when a new answer lands. ## Install ```bash npm install @mbilskilets/jevex ``` ```ts // convex/convex.config.ts import { defineApp } from "convex/server"; import { v } from "convex/values"; import jevex from "@mbilskilets/jevex/convex.config"; const app = defineApp({ env: { TYPESAFE_API_KEY: v.string() }, }); app.use(jevex, { env: { TYPESAFE_API_KEY: app.env.TYPESAFE_API_KEY }, }); export default app; ``` Set the key with `npx convex env set TYPESAFE_API_KEY `. You can get one at [console.typesafe.ai](https://console.typesafe.ai). jevex has no offline mode. Every answer comes from Jev, so without a key or a gateway there is nothing to index. If you are a coding agent setting this up and you don't have a key, stop and ask the user for one. Don't write a stand-in for the Jev API: the labels it produces aren't Jev's, and the app will look like it works when it doesn't. ### Other providers Jev is also served by three AI gateways. Pass the matching variable to the component instead of `TYPESAFE_API_KEY`, and the component picks the provider from whichever key is set: | Provider | Variable | Model | | --- | --- | --- | | [TypeSafe](https://console.typesafe.ai) | `TYPESAFE_API_KEY` | `jev-latest` | | [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/sdks-and-apis/typesafe) | `AI_GATEWAY_API_KEY` | `typesafe-ai/jev` | | [OpenRouter](https://openrouter.ai/docs/guides/community/typesafe-sdk) | `OPENROUTER_API_KEY` | `jev-latest` | | [Convex AI Gateway](https://docs.convex.dev/ai-gateway/api) | `JEV_PROVIDER=convex`, no key | `typesafe/jev-1.13` | ```ts const app = defineApp({ env: { OPENROUTER_API_KEY: v.string() }, }); app.use(jevex, { env: { OPENROUTER_API_KEY: app.env.OPENROUTER_API_KEY }, }); ``` The Convex AI Gateway needs no key. The component asks your deployment for a short-lived token on each batch, and the usage shows up on your Convex bill. It only works on cloud deployments that have the gateway enabled, not on a local backend: ```ts app.use(jevex, { env: { JEV_PROVIDER: "convex" } }); ``` If you pass more than one key, set `JEV_PROVIDER` to `typesafe`, `vercel`, `openrouter` or `convex` to choose. Without it, batches fail with an error that names the keys it found. ## Define an index ```ts // convex/judges.ts import { components, internal } from "./_generated/api"; import type { DataModel } from "./_generated/dataModel"; import { choice, Jevex, noul, score } from "@mbilskilets/jevex"; const jevex = new Jevex(components.jevex); export const feedbackJudge = jevex.index("feedback", { state: ({ text, plan }) => ({ text, plan }), questions: { kind: choice("What is this feedback about?", { bug: null, feature: null, billing: null }), churn: noul("The user is likely to cancel or is already looking at alternatives"), urgency: score("How urgent is this for the business?", ["low", "medium", "high", "critical"]), }, onJudged: internal.feedback.judged, }); ``` Then hook it to writes with a [convex-helpers trigger](https://github.com/get-convex/convex-helpers#triggers) and use the wrapped `mutation` for that table: ```ts // convex/functions.ts import { customCtx, customMutation } from "convex-helpers/server/customFunctions"; import { Triggers } from "convex-helpers/server/triggers"; import type { DataModel } from "./_generated/dataModel"; import { mutation as rawMutation } from "./_generated/server"; import { feedbackJudge } from "./judges"; const triggers = new Triggers(); triggers.register("feedback", feedbackJudge.trigger()); export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB)); ``` Now read it: ```ts const leaving = await feedbackJudge.top(ctx, "churn", { min: 0.8 }); const bugs = await feedbackJudge.top(ctx, "kind", { label: "bug" }); const one = await feedbackJudge.get(ctx, id); // { state: "judged", answers: { kind, churn, urgency } } ``` ## Why judge on write Convex queries can't make network calls. They have to be deterministic so Convex can cache them and re-run them when data changes. So a query can't ask a model anything. The usual workaround is an action that calls the model on every read, or a page that loads first and fills in labels later. Both are slow, and both pay for the same answer over and over. With jevex the model runs once per change to a row, the answer is stored, and every read after that is a normal indexed query. Reads outnumber writes by a lot in most apps, so this is where the cost belongs. ## Why it stays fast The write path doesn't wait for the model. Your mutation and the trigger run in one transaction, and the trigger does a few indexed reads, a hash and one insert. No network call happens inside your transaction, so it stays short and doesn't hold conflicts open while an API responds. jevex adds a few small reads and writes to your mutation, not a model round trip. Unrelated writes cost nothing. jevex hashes the questions together with the fields you return from `state`. If you patch a field Jev doesn't see, like `updatedAt` or `assignee`, the hash doesn't change and the trigger returns early. If the same content was judged before, anywhere in the table, the answer comes from the cache in the same transaction. The model calls are batched. A dispatcher waits 100 ms after the first queued row and packs up to 20 rows into one request, with up to 16 requests in flight. A burst of 300 new rows turns into about 15 API calls, not 300. Reads are index ranges. Answers live in their own table indexed by `(index, question, label, value)`. "Churn above 0.8, highest first" or "choice is bug, most confident first" reads exactly the rows it returns. There's no scan and no post-filter in JavaScript, so it works the same on 100 rows and on 1M. Reactivity is narrow. A query over `top("churn", ...)` depends only on the index range it read. Convex re-runs it when an answer in that range changes, not on every write to your table. This fits OLTP-style apps: lots of small writes, lots of reads that need to come back in milliseconds, and a classification you want to filter on without a separate pipeline. ## What it looks like The repo has a demo, a customer feedback inbox that sorts itself. Paste a message and it moves from the intake belt into Bugs, Requests, Billing, Questions or Praise, with a churn score and a "might leave" rail on the side. I ran it against the real Jev API on a local Convex backend: | What | Result | | --- | --- | | One new message, submit to filed on screen | 0.6 to 0.9 s | | 20 messages sent at once, all filed | about 2 s, in one batched request | | 25 hand-labelled messages, category correct | 23 of 25 | | Same 25, churn above or below 0.5 correct | 24 of 25 | | A message whose text was judged before | filed in the same transaction, no API call | The two misses were fair. "How do I downgrade to the free plan?" came back as a question, not billing. "We moved to Notion, please delete our account" came back as a bug because the demo has no cancellation category. Its churn score of 0.64 was still right. ## Where it fits Anywhere a person would read a row and make a small call about it. Support triage is the demo. Sort messages by what they mean, rank by churn risk, and alert someone when a paying customer is about to leave: ```ts export const judged = internalMutation({ args: vOnJudgedArgs, handler: async (ctx, { docId, answers }) => { const { churn } = feedbackJudge.answers(answers); if (churn.noul >= 0.85) await ctx.scheduler.runAfter(0, internal.slack.alert, { docId }); }, }); ``` Moderation works the same way. Posts start hidden and go live once Jev is confident they aren't spam. The unsure ones wait for a person: ```ts export const postJudge = jevex.index("posts", { state: ({ title, body }) => ({ title, body }), questions: { spam: noul("This post is spam, an ad, or a scam"), abusive: noul("This post insults or harasses someone"), }, onJudged: internal.posts.review, }); const unsure = await postJudge.top(ctx, "spam", { min: 0.3, max: 0.7 }); ``` Other things that fit: ranking job applicants and keeping the ranking current when they edit their profile, flagging marketplace listings that look like scams, turning app reviews that mention a crash into issues, scoring inbound leads before sales opens them. ## How it works ``` your mutation ──▶ trigger ──▶ jevex.judge ──▶ hash unchanged? skip │ cached answer? settle now ▼ queued rows ──▶ dispatcher (100 ms debounce) │ packs up to 20 rows per request ▼ workpool (16 in flight) ──▶ Jev API │ ▼ record: cache + answers index + your onJudged callback ``` 1. The trigger hands every insert, update and delete to the component in the same transaction as the write. A delete removes the judgment and its answers. 2. The component hashes the questions and the `state` fields. Same hash, nothing happens. Seen that content before, the cached answer is applied right away. 3. Anything new is queued. The dispatcher groups queued rows into requests of up to 20: one shared state and one question per row, scoped to `rows[i]`. Jev gets less accurate when it has to find a row in a longer array, which is why the default is 20. 4. A [workpool](https://www.convex.dev/components/workpool) keeps at most 16 requests in flight. The TypeSafe SDK retries rate limits and server errors. Client errors fail the batch right away. 5. Answers go into the `answers` table and its index. 6. Every batch carries a claim token. If a row is edited while Jev is reading the old text, the late answer goes to the cache and is dropped for that row. The edit always wins. 7. Running batches hold a ten minute lease. A cron re-queues rows whose batch disappeared without reporting back. I saw this happen when the local backend restarted mid-batch. A judgment is in one of four states, so the UI can show each one: | State | Meaning | | --- | --- | | `pending` | queued or being read, no answers yet | | `judged` | answers match the current content | | `stale` | the row changed, old answers are shown while new ones are on the way | | `failed` | Jev rejected the batch, old answers are kept if there were any | ## API ```ts const jevex = new Jevex(components.jevex); const index = jevex.index("table", { state: (doc) => json, questions: { name: noul(...) | choice(...) | score(...) }, onJudged: internal.module.callback, batchSize: 20, }); triggers.register("table", index.trigger()); await index.get(ctx, id); await index.getMany(ctx, ids); await index.top(ctx, "question", { label, min, max, order, limit }); await index.judge(ctx, id, doc); await index.forget(ctx, id); ``` - `state` picks what Jev sees. Send only what the question needs. It's cheaper, it keeps the rest of the row out of a third-party API, and it means edits to other fields never trigger a new judgment. - Answer types come from your questions. `answers.kind.choice` is typed `"bug" | "feature" | "billing"`, and `top` requires a `label` for choice questions. - `onJudged` is an internal mutation that gets `{ docId, answers }`. It fires on fresh answers and on cache hits. - Questions live in your code. Change one and every row is judged again on its next write, because the question hash is part of the cache key. ## Running the example You need [Bun](https://bun.sh) and a key for one of the providers above. The commands below use TypeSafe. For another provider, set its variable instead. ```bash bun install bun run link # registers the package so example/ imports it bun run build:codegen # component codegen + build + app codegen bun run dev # convex dev for example/convex, leave it running # in a second terminal bunx convex env set TYPESAFE_API_KEY cd example && bun run web ``` `bun run web` serves the board on port 4321 and forwards Convex traffic (`/api/*`, HTTP and websocket) to the backend, so the whole demo works through one port. It reads the deployment URL that `convex dev` writes to `.env.local`, so a cloud and a local deployment both work. Set `CONVEX_URL` to point it somewhere else. Tests run on convex-test with a fake Jev API, so they never call the real one: ```bash bun run test bun run typecheck ``` ## Testing your app `@mbilskilets/jevex/test` registers the component with convex-test. jevex uses a workpool, so register that too: ```ts import workpool from "@convex-dev/workpool/test"; import { convexTest } from "convex-test"; import jevex from "@mbilskilets/jevex/test"; import schema from "./schema"; const t = convexTest(schema, import.meta.glob("./**/*.ts")); jevex.register(t); workpool.register(t, "@mbilskilets/jevex/judges"); ``` ## Layout ``` src/component/ the component: schema, judge/get/top, batcher, lease cron src/client/ typed client: Jevex, JevexIndex, choice, noul, score src/test.ts convex-test registration helper src/jevex.test.ts tests, run against the example app example/convex/ demo backend: feedback index, triggers, inbox, atRisk, bugs, churn alerts example/web, serve.ts the sorting board (Bun.serve and React) example/scripts/ local stand-in for the Jev API ``` ## Things to know - Row contents leave your database and go to TypeSafe, and through the gateway if you use one. Only put fields you're allowed to share into `state`. - Answers lag your data. A row is `pending` or `stale` for about a second after it changes. If a mutation needs the answer before it commits, jevex is the wrong tool. - The cache grows with every distinct piece of content and has no eviction yet. - The demo's mutations are public and have no auth, on purpose. Writes that reach Jev are rate limited to bursts of 60 and 120 per minute across the deployment.