Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Jevex

A small Bijection component that uses Jev to build a semantic index over your tables.

Bijection source
git clone https://github.com/bijectionhq/jevex.git
cd jevex
git checkout fc6cb6fa66579d55968bfede6ac4d3e828af3e40

Bijection repository access is required. Request access.

Description

A small Bijection component that uses Jev to build a semantic index over your tables.

Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. Exercise the component in your deployment according to your project’s verification rules.

Open source

Maintained source: bijectionhq/jevex. Based on mbilskilets/jevex. License: MIT. Consult the repository for license terms, notices, and contributor attribution.

From the upstream publisher

A small Convex component that uses Jev to build a semantic index over your tables.

Explore the demo ↗

Upstream overview

From the upstream package documentation.

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

From the upstream package documentation.

  • 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

From the upstream package documentation.

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 package FAQ

Does Jevex make model API calls during Convex queries?

No. Jevex stores Jev answers in a Convex table with an index on (index, question, label, value). Queries like feedbackJudge.top(ctx, 'churn', { min: 0.8 }) read an index range and return immediately. Model calls happen on write, triggered once per changed row, and results are cached so identical content is never sent to Jev twice.

How does Jevex avoid slowing down Convex mutations?

The trigger that Jevex registers with convex-helpers does a few indexed reads, a hash check, and one insert inside the same transaction as your mutation. No network call happens in the transaction. If the state hash is unchanged the trigger returns early. New rows are queued and processed by a dispatcher that batches up to 20 rows per Jev request with a 100 ms debounce.

What API key do I need to use Jevex?

Jevex requires access to the Jev model, which is available through four providers: TypeSafe (TYPESAFE_API_KEY), Vercel AI Gateway (AI_GATEWAY_API_KEY), OpenRouter (OPENROUTER_API_KEY), or the Convex AI Gateway (JEV_PROVIDER=convex, no key needed). The Convex AI Gateway option only works on cloud deployments with the gateway enabled, not on a local backend.

What question types does Jevex support for classification?

Jevex exposes three question helpers: noul for a 0-to-1 probability score ('the user is likely to cancel'), choice for a typed enum label ('what is this feedback about?' with options like bug, feature, or billing), and score for an ordered categorical value ('how urgent is this?'). Each answer type is reflected in the TypeScript types returned by index.get and index.top.

How does Jevex handle a row that changes while Jev is processing the previous version?

Each batch carries a claim token tied to the row's content at the time it was queued. If the row is edited before Jev responds, the late answer is written to the cache but dropped for that row, and the new content is queued separately. The most recent edit always wins. Jevex also runs a cron that re-queues any rows whose batch disappeared without reporting back, such as after a backend restart.

Use with agents and CLI

Give your coding agent the component’s source identity and setup guidance.

agent-prompt.md
Help me integrate Jevex with Bijection.

Read: https://bijection.com/components/jevex/SKILL.md

Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.

Upstream README.md

View repository ↗

Documentation from mbilskilets/jevex, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.

jevex

A small Convex component that uses Jev 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.

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

npm install @mbilskilets/jevex
// 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 <your key>. You can get one at 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:

ProviderVariableModel
TypeSafeTYPESAFE_API_KEYjev-latest
Vercel AI GatewayAI_GATEWAY_API_KEYtypesafe-ai/jev
OpenRouterOPENROUTER_API_KEYjev-latest
Convex AI GatewayJEV_PROVIDER=convex, no keytypesafe/jev-1.13
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:

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

// 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<DataModel>(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 and use the wrapped mutation for that table:

// 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<DataModel>();
triggers.register("feedback", feedbackJudge.trigger());

export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));

Now read it:

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:

WhatResult
One new message, submit to filed on screen0.6 to 0.9 s
20 messages sent at once, all filedabout 2 s, in one batched request
25 hand-labelled messages, category correct23 of 25
Same 25, churn above or below 0.5 correct24 of 25
A message whose text was judged beforefiled 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:

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:

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 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:

StateMeaning
pendingqueued or being read, no answers yet
judgedanswers match the current content
stalethe row changed, old answers are shown while new ones are on the way
failedJev rejected the batch, old answers are kept if there were any

API

const jevex = new Jevex<DataModel>(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 and a key for one of the providers above. The commands below use TypeSafe. For another provider, set its variable instead.

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 <your 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:

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:

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.

Keywords