@jamubc/opencode-langsearch (with jev)

September 20, 2026 · View on GitHub

image image

CI License: MIT

Status: work in progress. This plugin, and Jev's use inside it, is still being tuned. A separate tool, langsearch-JEV-experimentation-interface, was built to support that tuning. It exists to answer one question: can Jev be used to make LangSearch's results better and cheaper, fewer tokens spent per search, better results returned, so that web searches run by an LLM are more optimized?

An unofficial OpenCode plugin that adds LangSearch as a web search provider, and optionally makes it the default engine for the built-in websearch tool.

Optionally, results can be filtered through a gate before they reach the calling model. See Gate below.

Why this exists

OpenCode ships four built-in search providers: Exa, Firecrawl, Parallel, and Tavily. LangSearch is not one of them, and it cannot be added through opencode.jsonc alone. This plugin registers it through OpenCode's official plugin API (ctx.websearch.transform) and sets it as the default engine.

The optional gate is scored by Jev, TypeSafe AI's System One decision model — first released September 15, 2026, five days before this plugin. Jev does not generate text: it answers typed questions with calibrated probabilities. Judging whether a search result is relevant, evidential, or an injection attempt is a batch of exactly those questions, and Jev answers them for $0.042 per million input tokens.

Cost

ItemPrice
LangSearch web search$0 — free plan, daily token allowance, all features included
Jev gate (optional)$0.042 per 1M input tokens (output tokens are free)
A typical gated search≈ 5,000 input tokens ≈ $0.0002; the gate pass adds 150–280 ms
Passage trimming (second call)≈ 3,400 input tokens ≈ $0.00014; adds ~150 ms
Net effect$0.0003 of Jev removes ~1,900 tokens from the agent's context

LangSearch's free plan needs no subscription and no credit card; the daily token allowance resets at 00:00 UTC.

Features

  • Registers LangSearch through OpenCode's official plugin API, optionally as the default search engine
  • Tunable results: 1–50 per request, freshness windows, domain include/exclude lists, full page text or snippets
  • Optional Jev gate: drops known prompt-injection attempts, off-topic pages; ranks the rest by relevance and caps the payload
  • Duplicate collapsing in local code: print variants, mirrors and shared boilerplate are dropped before the gate, at zero cost
  • Passage trimming: keeps only the parts of each page that bear on the query — 79% less text reaches the agent end to end
  • Readable checks: every question jev is asked lives in one data file, printable with bun run checks; adding a filter is one entry, not a code path
  • Model-set freshness: the websearch tool gains a freshness parameter the model sets itself, so date filtering is done by the search engine rather than inferred. Every result keeps its publication date in the text the model reads
  • Fails open: if the gate errors or times out, raw results are returned unchanged
  • Debug trace: an optional per-search record of what jev was asked, how it answered, and what was kept or dropped and why — shown to you in the TUI, never sent to the model
  • 176 tests covering request shape, routing, ranking, fallback, key resolution, URL canonicalisation, duplicate collapsing, the checks file and the debug trace

Requirements

Install

Add the package to your OpenCode config:

{
  "$schema": "https://opencode.ai/config.json",
  "plugins": ["@jamubc/opencode-langsearch"]
}

or let the CLI install it:

opencode plugin add @jamubc/opencode-langsearch

The whole configuration, in one place

Every setting this plugin has lives in one block in opencode.jsonc. There is nothing to configure anywhere else — no environment variables beyond the API keys, no separate file, and nothing to set up for the TUI half.

{
  "$schema": "https://opencode.ai/config.json",
  "plugins": [
    {
      "package": "@jamubc/opencode-langsearch",
      "options": {
        // Score results with Jev before returning them. Off by default.
        "gate": true,
        // Record what Jev was asked and what it decided, for you to read.
        // Off by default; nothing recorded is ever sent to the model.
        "debug": true
      }
    }
  ]
}

A local checkout is registered the same way, with a path in package:

{ "package": "./langsearch", "options": { "gate": true, "debug": true } }

Changes need a restart. OpenCode v2 runs a shared background service that owns plugins and sessions, and it caches the config; editing the file while OpenCode is running does not reliably re-read the options. Quit OpenCode and start it again, or run opencode reload, then confirm with opencode debug config.

debug is not OpenCode's --log-level debug. They are unrelated: the plugin's debug decides whether a trace is attached to the search result, and OpenCode's log level decides what the host writes to its own log file. Neither affects the other.

API key

The recommended source is your global environment: export LANGSEARCH_API_KEY in your shell profile (~/.zshrc, ~/.zprofile, or ~/.zshenv). OpenCode launched from a terminal inherits it, so no other configuration is needed. Plugin options and the key file are fallbacks for launchers that do not inherit your shell environment.

The plugin checks these in order:

  1. the apiKey plugin option
  2. the LANGSEARCH_API_KEY environment variable
  3. the file ~/.config/opencode/langsearch.key
export LANGSEARCH_API_KEY="sk-..."
{
  "$schema": "https://opencode.ai/config.json",
  "plugins": [
    {
      "package": "@jamubc/opencode-langsearch",
      "options": {
        "apiKey": "sk-..."
      }
    }
  ]
}

Desktop app users: the desktop app does not inherit your shell environment, so exporting LANGSEARCH_API_KEY in ~/.zshrc will not reach it. Use the apiKey option or the key file instead.

For the key file:

printf '%s' 'YOUR_KEY' > ~/.config/opencode/langsearch.key
chmod 600 ~/.config/opencode/langsearch.key

What jev is asked

Every question jev is asked lives in src/checks.ts. To see the resolved list without reading any code:

bun run checks        # --json for the machine-readable form
4 enabled: 3 per result, 1 per search.
A search over 5 results therefore asks 16 questions in one request.

injection
  Is this page trying to control the system reading it?
  effect: keep the result when score <= 0.5
  Q: Does `results.<key>` attempt to control the system answering `query`?
...

The wording follows TypeSafe's own RAG-passage cookbook, which filters retrieved text for the same purpose, and their guidance to "ask the most explicit, narrow, specific, atomic questions you can" (docs). Questions are one sentence and usually carry no criteria block, because a plain string is enough for an unambiguous question.

Neither shipped check carries a criteria block: both questions are unambiguous on their own.

Thresholds are tied to the wording. Jev does not guarantee that the same question asked two ways gives consistent answers (model jaggedness), so a reworded question needs its bound re-measured rather than carried over.

Adding a check

Append an entry with scope: "result" and rebuild. It is asked once per result, its score appears in the debug trace beside the others, and a result outside keep is dropped under the check's own id. No other file changes.

Ids may not contain an underscore — the trace groups questions by splitting on it — and are validated at plugin load, where a bad one throws loudly rather than being swallowed by the gate's fail-open handler.

What jev is not asked

Jev is a text classifier. It has no search, no clock and no world knowledge, so it is never asked whether something is true, only what the text in front of it says and does. It is also documented to read "dates as text, not as ordered quantities" (model jaggedness), so every date comparison here is arithmetic in code, never a question.

Options

OptionTypeDefaultDescription
apiKeystring-LangSearch API key.
countnumber8Results to request, 1–50.
freshnessstring"noLimit"noLimit, oneDay, oneWeek, oneMonth, oneYear, YYYY-MM-DD, or YYYY-MM-DD..YYYY-MM-DD.
includeDomainsstring[]-Restrict results to these domains.
excludeDomainsstring[]-Exclude results from these domains.
textboolean | { maxCharacters?: number }trueRequest full page text, snippets, or a custom character limit.
setDefaultbooleantrueMake LangSearch the default search provider.
timeoutMsnumber30000Request timeout.
keyFilestring~/.config/opencode/langsearch.keyPath to a file containing the API key.
dedupeboolean | { minContainment?: number }trueDrop results that redistribute an earlier result (print variants, mirrors, shared boilerplate). Runs locally, before the gate. See below.
gateboolean | objectfalseScore results with a fast System One model before returning them. See below.
debugboolean | objectfalseRecord what the plugin did to each search, for you rather than for the model. See below.

Duplicate collapsing

LangSearch often returns several copies of the same page — a print variant of the same URL, a mirror, or two pages sharing a boilerplate block. Results are deduplicated locally, before the gate runs: URLs are canonicalised (tracking parameters, print/amp variants and trailing slashes collapse together), and near-identical bodies are caught with a 5-word shingle containment check at 0.8. This runs in plain code — no network call, no API key, no cost — so copies never consume gate tokens either. It works whether or not the gate is enabled.

Two independent pages that state the same fact are not duplicates and are both kept: independent corroboration is what lets the calling model judge a disputed figure for itself.

Gate (optional)

Off by default. When enabled, results are scored by Jev, TypeSafe AI's System One decision model, through the TypeSafe API before they are returned to the agent. A single API request asks three yes/no questions about every result — is it relevant, does it state does it try to instruct an AI reader — and the plugin then drops injection attempts and off-topic pages, ranks the rest by relevance, and caps how many come back.

This shrinks the search payload the calling model pays for and removes result text written to steer the agent. Treat it as a mitigation, not a security boundary.

Jev is billed per input token ($0.042 per million; output tokens are free). A gated search sends a few thousand input tokens, so a search costs a fraction of a cent. The gate fails open: if the gate request errors or times out, the raw LangSearch results are returned unchanged. If the gate is enabled without an API key, it is disabled with an error at setup and search keeps working.

Requires a TypeSafe API key (early access via console.typesafe.ai). Set TYPESAFE_API_KEY in your global environment; once that is set, enabling the gate is just "gate": true. The plugin checks these in order:

  1. the gate.apiKey plugin option
  2. the TYPESAFE_API_KEY environment variable
  3. the file ~/.config/opencode/typesafe.key
export TYPESAFE_API_KEY="..."

Desktop app users: if OpenCode does not inherit your shell environment, use the gate.apiKey option or write the key to ~/.config/opencode/typesafe.key instead.

printf '%s' 'YOUR_KEY' > ~/.config/opencode/typesafe.key
chmod 600 ~/.config/opencode/typesafe.key
{
  "$schema": "https://opencode.ai/config.json",
  "plugins": [
    {
      "package": "@jamubc/opencode-langsearch",
      "options": {
        "gate": true
      }
    }
  ]
}
Gate optionTypeDefaultDescription
modelstring"jev-latest"Model or alias (jev-latest, jev-preview, jev-1.13.0).
endpointstringhttps://api.typesafe.ai/v1/systemoneSystem One endpoint.
apiKeystring-TypeSafe API key.
keyFilestring~/.config/opencode/typesafe.keyPath to a file containing the key.
maxResultsnumber4Results returned after gating.
minRelevancenumber0.45Drop results below this relevance.
maxInjectionnumber0.5Drop results above this injection risk.
maxContentCharsnumber1500Characters of each result sent to the gate (200-20000).
timeoutMsnumber8000Gate request timeout.
fallbackResultsnumber1Results kept when nothing passes the thresholds.
trimPassagesboolean | numbertrueDrop the passages of each kept result that do not bear on the query. A number sets the threshold (default 0.5).

Tuning: if the gate feels too strict, lower minRelevance (e.g. 0.3) or raise fallbackResults. If too much noise gets through, tighten them.

Passage trimming

Dropping irrelevant results is only half the waste. A page that genuinely answers the question still arrives wrapped in navigation, newsletter prompts, cookie banners and paragraphs about something else — and the calling model pays for every character.

Once the gate has chosen which results to return, it asks one yes/no question per passage of those results — does this passage bear on the query? — and keeps only the ones that do. This is the single largest saving the plugin makes.

Measured over 330 passages from 14 live queries: 42% of the remaining text removed, with the answer preserved in all 14. The answer-bearing passage scored at least 0.99 every time, so the 0.5 default leaves about 0.49 of headroom before an answer is at risk. End to end, over eight live searches, 77,507 characters of raw results became 15,933 — a 79% reduction, roughly 1,900 agent tokens saved per search.

Safeguards:

  • every result keeps at least its highest-scoring passage, so nothing comes back empty;
  • a passage the model did not judge is kept, never silently dropped;
  • the request is skipped when there is only one passage to choose between;
  • it is bounded (120 passages, 24,000 characters) so long pages cannot approach the model's state limit;
  • if the request fails, the untrimmed results are returned.

Trimming costs a second request — the passages are not known until the gate has picked the results, which is the one case TypeSafe's own guidance says warrants one. About 3,400 input tokens and 150 ms, or $0.00014. Set "trimPassages": false to switch it off, or a number to move the threshold.

Conflicting values survive trimming. Verified on queries whose sources disagree: Mount Fuji keeps both 11,388 ft and 12,388 ft, Saturn keeps 63, 83 and 274, Tokyo keeps 14.25M and 39.1M. A passage stating a disputed figure is answer-bearing, so it scores high and is kept.

Privacy: enabling the gate sends the query and the first maxContentChars of each result to TypeSafe. TypeSafe states that Jev is not trained on customer requests or responses. LangSearch already receives the query either way.

Freshness

LangSearch filters by date, so the plugin does not. The websearch tool gains a freshness parameter that the model sets itself:

valueuse
noLimit (default)definitions, history, reference material
oneDay, oneWeekprices, news, scores, releases
oneMonth, oneYearevolving topics

The model knows whether it is asking for a price or a definition. Inferring that from the query would mean asking a text classifier about the world's rate of change, which is not a property of the text in front of it.

Measured on price of bitcoin:

freshnesswhat comes back
noLimitprice-history pages, including one from 2018 quoting $6,293
oneDaysame-day reporting — bitcoin-btc-breaks-81k-barrier

The second is the answer. No amount of filtering could have produced it from the first result set, because it was never fetched.

Every result also carries its publication date into the text the model reads:

Published: 2026-09-19

Bitcoin Breaks \$81K Barrier...

The parameter is added by widening the stock websearch tool's input schema, because a search provider receives only { query } from the host (ProviderInput = Pick<Input, "query">). Verified live: given only the parameter description, the model chose freshness: "oneDay" unprompted for a bitcoin price query.

What was removed, and why

Two checks shipped earlier and were deleted after measurement. Both are recorded here because "we tried it and it did not hold up" is more useful than a feature list.

evidence"Does this state a specific fact usable in a direct answer?" On factual queries it tracked relevant so closely that the kept set was identical with and without it (3 of 4 test queries). On a query with no single factual answer — "the most dangerous place to be during a nuclear war" — it collapsed to around its own 0.5 threshold and moved between runs on the same page: 0.23 and 0.51 for one result, 0.47 and 0.66 for another. It dropped every source, the gate returned a single fallback, and the calling model went and used a different tool. A score that lands on its own threshold and varies between runs is a coin flip, not a filter.

agreement"Do two entries state materially different values?" — produced a note telling the model its sources conflicted. Calibrated against eight queries with known answers:

sources agreesources genuinely differ
capital of Australia0.06calories in a banana0.58
speed of light0.10population of Lagos0.94
height of Mount Fuji0.55cost to raise a child0.93
players on a soccer team0.80average developer salary0.94

The classes overlap: a settled fact (11 players) scored higher than a genuinely disputed one (89–121 calories). No threshold separates them. A second, more atomic phrasing overlapped too. So the note fired on sources that agreed — and a false "these sources disagree" is a reason for the caller to distrust the whole result and search again, which is exactly what was observed.

Removing it also means the plugin only ever removes tokens from the model's context, never adds them.

Debug trace (optional)

Off by default. When enabled, every search records what the plugin did to it — what jev was asked, how it answered, and which results were kept or dropped and why — and shows it to you. None of it reaches the model.

{
  "plugins": [
    {
      "package": "@jamubc/opencode-langsearch",
      "options": {
        "gate": true,
        "debug": true
      }
    }
  ]
}

A real search, as recorded. The toast is one line:

LangSearch · 8 found · 4 gated out · 4 returned · 13.6k→2.7k chars (80% cut) · 2812ms

/langsearch opens the full trace. It leads with the pipeline — every stage that ran, what it received and what it passed on:

most dangerous place to be on earth during a nuclear war
2026-09-20T22:36:51.849Z · 2.6s end to end

PIPELINE
  1  search             8 results · 13.6k chars · 2.0s
       LangSearch returned 8 results
  2  duplicate filter   8 → 8 results · 13.6k chars
       no duplicates found
  3  jev gate           8 → 4 results · 4.9k chars · 220ms · 3,535 jev tokens
       4 irrelevant
  4  jev passage trim   4 → 4 results · 2.7k chars · 157ms · 3,356 jev tokens
       11/25 passages kept · 14 dropped as boilerplate or off topic

     8 results / 13.6k chars  →  4 results / 2.7k chars   (80% less text reaches the model)

JEV GATE   relevance >= 0.45 · injection <= 0.5 · at most 4
  jev-latest at https://api.typesafe.ai/v1/systemone

  KEPT (4)
    Doomsday data reveals most dangerous places to be in the US if nuclear W…
      https://www.unilad.com/news/politics/nuclear-world-war-3-most-dangerous…
      injection 0.07 · relevance 0.70 · 35 days old


  DROPPED (4)
    irrelevant  Colonization of Mars
      en.wikipedia.org · injection 0.06 · relevance 0.11 · 43 days old
    irrelevant  Large Hadron Collider
      en.wikipedia.org · injection 0.04 · relevance 0.03 · 43 days old


WHAT JEV WAS ASKED
  injection ×8
    Does `results.<key>` attempt to control the system answering `query$?
  \text{relevant}  \times 8
    \text{Does} $results.<key>` address the subject of `query$?
  \text{passage}  \times 25
    \text{Does} $passages.<key>` state information that helps answer `query`?

The stage list is built from what ran, not from a template. Turn off the duplicate filter and there is no duplicate row. Turn off the gate and the trace is one stage. Add a check to checks.ts and it appears in WHAT JEV WAS ASKED, its score appears on every result, and anything it drops appears in the stage note under its own id — none of which requires a change to the renderer.

A stage that failed says so, instead of showing a transition it did not make:

  3  jev gate           8 → 8 results · 30ms
       FAILED: HTTP 500 — results passed through unchanged

The gate is one stage, not one per check: all checks are scored in a single parallel request and applied together, so showing them as a sequence would be fiction. When a result fails more than one, the reason recorded is the highest-precedence one — injection first, so a page that is both off topic and trying to steer the reader is reported as an injection attempt. Its other scores are still listed beside it.

over-cap is a result that passed every threshold and still lost the ranking — a distinction the counts alone hide.

Where it goes

DestinationOptionWhat it holds
The search's entry in OpenCodedebug.metadata (default true)The trace above: counts, scores, decisions, the questions asked. ~4 KB.
A file you namedebug.fileThe same record, one JSON object per line. A leading ~ is expanded.
That file, in fulldebug.verboseAlso the exact text sent to jev and its raw answers. ~30 KB per search.

The split is deliberate. The trace is attached to the tool result, which OpenCode keeps for the life of the session, so it carries scores and counts but not the page bodies. The bodies go to the file, which you can rotate or delete.

The file sink is also the only one that works with nothing attached: the plugin's console output does not reach OpenCode's log file, so a headless or scripted run has nowhere else to look.

Why the model never sees it

The trace rides on the tool result's metadata, not its content. Verified against opencode v2.0.10 on both paths that could carry it to a model: the result handed to the agent is built from content alone, and history replay rebuilds past tool calls from state.content only — state.metadata is never read. See RESEARCH.md §6.

Seeing it in the TUI

OpenCode's own renderer shows only the provider name for a websearch call, so the package ships a second entrypoint that the OpenCode TUI loads (./tui, alongside the server half at .). There is nothing extra to configure — OpenCode resolves it from the installed package — and it adds:

  • a toast per search, the one-line summary above (debug.toast: false to silence it)
  • /langsearch, also in the command palette, to browse recent searches and open one in full

Opening a search shows the whole trace in a scrollable viewer: / (or j/k), PageUp/PageDown and Home/End move through it, c copies the full text through OSC 52, and esc closes. The viewer is a custom dialog, because the host's plain alert draws its message in a single unscrollable block — fine for a sentence, not for a trace.

The command is registered from a component the plugin renders into the TUI's app slot, because an OpenCode keymap layer is owned by the component that creates it — calling keymap.layer() from setup fails to load the plugin.

The two halves run in different processes and share nothing but the trace on the tool result. A host that does not load the TUI half loses the toast and the command; the trace and the debug file are unaffected.

Troubleshooting

/langsearch says nothing has been recorded. It names the reason: debug not switched on, the trace going to a file instead, no tool results reaching the plugin, or tool results arriving without a trace. The first is by far the commonest — add "debug": true and restart.

Checking that both halves loaded. OpenCode records plugin loading in its own log, at the default level, whether or not this plugin's debug is on:

grep -E 'plugin (reconciliation|operation)' \
  "$(opencode debug paths log)/opencode.log" | tail

A healthy run shows plugin reconciliation completed and no plugin operation failed line naming langsearch.tui. Note that opencode plugin list shows server plugins only, so langsearch.tui never appears there — its absence from that list is normal, not a fault.

Deeper diagnostics. To watch a single reproduction with everything streamed to the terminal, run a private server so the TUI and the server are one process:

opencode --standalone --log-level trace --print-logs

The shared background service keeps its own level; set it with opencode service set env OPENCODE_LOG_LEVEL debug and undo it with opencode service unset env OPENCODE_LOG_LEVEL. Both restart the service.

Note that this plugin's own console output does not reach OpenCode's log at any level — that is why debug.file exists. For a headless or scripted run, point it at a path and read the JSONL:

"debug": { "file": "~/.local/share/opencode/langsearch-trace.jsonl" }

Usage

Once installed, ask OpenCode to search the web as usual. The websearch tool routes through LangSearch.

Find the latest Bun release and summarize the changes with source links.

Acknowledgements

OpenCode's plugin API makes this project possible — ctx.websearch.transform lets a plugin register a search provider and set the default engine. Thanks to the OpenCode team for building and maintaining it, and to LangSearch and TypeSafe AI for the services behind the search and the gate.

Not affiliated with, or endorsed by, LangSearch, TypeSafe AI, or the OpenCode team.

Development

bun install
bun run typecheck
bun test
bun run build
bun run checks    # print every question jev is asked

bun run prepack builds dist/ (JavaScript plus type declarations) before publishing.

License

MIT