@jamubc/opencode-langsearch (with jev)
September 20, 2026 · View on GitHub
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
| Item | Price |
|---|---|
| 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
websearchtool gains afreshnessparameter 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
- OpenCode v2 (uses the
@opencode/pluginv2 API) - A LangSearch API key, free at https://langsearch.com/dashboard
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:
- the
apiKeyplugin option - the
LANGSEARCH_API_KEYenvironment variable - 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_KEYin~/.zshrcwill not reach it. Use theapiKeyoption 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
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | - | LangSearch API key. |
count | number | 8 | Results to request, 1–50. |
freshness | string | "noLimit" | noLimit, oneDay, oneWeek, oneMonth, oneYear, YYYY-MM-DD, or YYYY-MM-DD..YYYY-MM-DD. |
includeDomains | string[] | - | Restrict results to these domains. |
excludeDomains | string[] | - | Exclude results from these domains. |
text | boolean | { maxCharacters?: number } | true | Request full page text, snippets, or a custom character limit. |
setDefault | boolean | true | Make LangSearch the default search provider. |
timeoutMs | number | 30000 | Request timeout. |
keyFile | string | ~/.config/opencode/langsearch.key | Path to a file containing the API key. |
dedupe | boolean | { minContainment?: number } | true | Drop results that redistribute an earlier result (print variants, mirrors, shared boilerplate). Runs locally, before the gate. See below. |
gate | boolean | object | false | Score results with a fast System One model before returning them. See below. |
debug | boolean | object | false | Record 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:
- the
gate.apiKeyplugin option - the
TYPESAFE_API_KEYenvironment variable - the file
~/.config/opencode/typesafe.key
export TYPESAFE_API_KEY="..."
Desktop app users: if OpenCode does not inherit your shell environment, use the
gate.apiKeyoption or write the key to~/.config/opencode/typesafe.keyinstead.
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 option | Type | Default | Description |
|---|---|---|---|
model | string | "jev-latest" | Model or alias (jev-latest, jev-preview, jev-1.13.0). |
endpoint | string | https://api.typesafe.ai/v1/systemone | System One endpoint. |
apiKey | string | - | TypeSafe API key. |
keyFile | string | ~/.config/opencode/typesafe.key | Path to a file containing the key. |
maxResults | number | 4 | Results returned after gating. |
minRelevance | number | 0.45 | Drop results below this relevance. |
maxInjection | number | 0.5 | Drop results above this injection risk. |
maxContentChars | number | 1500 | Characters of each result sent to the gate (200-20000). |
timeoutMs | number | 8000 | Gate request timeout. |
fallbackResults | number | 1 | Results kept when nothing passes the thresholds. |
trimPassages | boolean | number | true | Drop 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 raisefallbackResults. 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
maxContentCharsof 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:
| value | use |
|---|---|
noLimit (default) | definitions, history, reference material |
oneDay, oneWeek | prices, news, scores, releases |
oneMonth, oneYear | evolving 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:
| freshness | what comes back |
|---|---|
noLimit | price-history pages, including one from 2018 quoting $6,293 |
oneDay | same-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
websearchtool'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 chosefreshness: "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 agree | sources genuinely differ | ||
|---|---|---|---|
| capital of Australia | 0.06 | calories in a banana | 0.58 |
| speed of light | 0.10 | population of Lagos | 0.94 |
| height of Mount Fuji | 0.55 | cost to raise a child | 0.93 |
| players on a soccer team | 0.80 | average developer salary | 0.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
| Destination | Option | What it holds |
|---|---|---|
| The search's entry in OpenCode | debug.metadata (default true) | The trace above: counts, scores, decisions, the questions asked. ~4 KB. |
| A file you name | debug.file | The same record, one JSON object per line. A leading ~ is expanded. |
| That file, in full | debug.verbose | Also 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: falseto 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.