A plugin for opencode that gives local LLM models live internet access via Brave Search (primary) and Serper.dev (fallback).
Solves a real gap: when you point opencode at a local model (Ollama, LM Studio, Unsloth, llama.cpp, vLLM, Exo), the model has no built-in web search. Ask "what's the price of bitcoin?" and you get "I don't have internet access." This plugin fixes that by registering a web_search tool the model can call like any other.
Auto-enabled only when the active session's provider matches a local pattern. Stays out of the way for hosted models (Anthropic, OpenAI, Google, etc.) that already ship native search.
opencode ships a built-in websearch tool backed by Exa AI — but it's gated to either the OpenCode provider or OPENCODE_ENABLE_EXA=1. For users running local models and preferring their own search backend (Brave/Serper, with their own API keys), this plugin is the right tool.
| Approach | Setup | Backend | Auto-detect local models |
|---|---|---|---|
Built-in websearch |
OPENCODE_ENABLE_EXA=1 env var |
Exa AI (hosted, no key) | No |
| This plugin | Install + 1 API key | Brave + Serper (your keys) | Yes |
Both can coexist. On a local model without Exa enabled, only this plugin's web_search is available. On hosted models, both can be available.
# 1. Drop the plugin into opencode's plugin directory
mkdir -p ~/.config/opencode/plugins
cp web-search.ts ~/.config/opencode/plugins/web-search.ts
# 2. Get API keys (both have free/cheap tiers)
# Brave: https://brave.com/search/api/ (free, 2,000/month)
# Serper: https://serper.dev/ (paid, cheap)
# 3. Set the keys (pick ONE method)Add to ~/.zshrc or ~/.bashrc:
export BRAVE_API_KEY="your_brave_key_here"
export SERPER_API_KEY="your_serper_key_here"Then source ~/.zshrc and restart opencode.
cp .env.example ~/.config/opencode/.env
nano ~/.config/opencode/.env # paste in your real keysRestart opencode. Done.
To enable for a single project only, drop it in the project's plugin dir:
mkdir -p .opencode/plugins
cp web-search.ts .opencode/plugins/web-search.tsProject plugins load after global plugins (so a project copy takes precedence).
- On startup, the plugin registers a
web_searchtool. - In the background, it runs a non-blocking query to pre-fetch the latest session's model context (done asynchronously to prevent deadlocking the opencode bootstrap loader).
- It subscribes to session events. When the user picks a model, it caches the provider ID.
- On every tool call, it re-checks the live session (via
client.session.get) for the freshest state. - If the provider ID or model ID matches a local pattern (
ollama,lmstudio,unsloth,llamacpp,vllm,exo), the search runs. Checking the combined string allows gateway setups like LiteLLM to work correctly (e.g., enabling search forlitellm-local/unsloth-gemma-4-12bbut gating/disabling it for remote models likelitellm-local/gpt-4o). - If hosted and not force-enabled, the tool returns a friendly message pointing the model at the built-in
websearch. - On model switches, a toast notification shows the new gating state.
The tool is always registered — there's no way to truly "unregister" a tool at runtime in opencode. Instead, behavior changes based on the current model. This matches the spirit of the pi plugin's setActiveTools toggle while staying within opencode's plugin API.
If you want web_search to run regardless of model (e.g., for testing, or you don't trust the auto-detection):
# Always run (even on hosted models)
export WEB_SEARCH_FORCE=on
# Never run (fully disable the tool)
export WEB_SEARCH_FORCE=off
# Default: only run for local models
export WEB_SEARCH_FORCE=autoYou can customize the search behavior using the following environment variables:
# Select primary search engine (brave or serper, default: brave)
export WEB_SEARCH_PRIMARY=serper
# Cap result text length (default 8000 chars)
export WEB_SEARCH_MAX_CHARS=4000
# Default result count (1-20, default 10)
export WEB_SEARCH_MAX_RESULTS=5- Native
web_searchtool the model calls autonomously - Dual-backend fallback (Brave primary, Serper fallback)
- Smart auto-detection for local models (ollama, lmstudio, unsloth, llamacpp, vllm, exo)
- Reactive to model switches (no restart needed, shows toast on change)
- Runtime re-check on every call (always reflects current state)
- Result truncation tunable for small context windows
- Zero npm dependencies (single file, uses native
fetch) - Works with shell exports OR
.envfiles (shell exports always win) - Structured logging via
client.app.log - Force override via
WEB_SEARCH_FORCEenv var
After installing and setting keys:
- Launch opencode with a local model (e.g. switch to Ollama).
- You should see a toast:
web_search enabled — local model ollama/.... - Ask: "What's the price of bitcoin right now?"
- The model should call
web_searchand return current data. - Switch to a hosted model (e.g. Anthropic).
- Toast:
web_search idle — hosted anthropic has native search. - Asking the same question, the model uses its own search or
web_searchreturns the friendly gate message.
For diagnostics, check opencode's logs (filtered by service: web-search):
plugin loaded — force=auto brave=true serper=true localPatterns=[ollama, lmstudio, ...]
model changed — provider=ollama model=llama3 local=true
searching — query="bitcoin price" count=10 provider=brave model=ollama/llama3
search complete — provider=brave results=10
The search functions are isolated and follow a simple signature:
async function searchX(query: string, count: number): Promise<SearchResult[]>To add a third backend:
- Implement
searchXfollowing thesearchBrave/searchSerperpattern. - Add
"x"to theProviderunion type. - Wire it into the fallback chain in
execute(). - Add the API key to
.env.example.
See docs/opencode-search-plugin.md for the full walkthrough.
- No hardcoded keys. The plugin reads from
process.envonly. - No telemetry. It makes one HTTP request per search to the configured backend.
.envis gitignored. The repo ships with.env.example(placeholders only).- No key collection. This repo will never ask for, log, or transmit your API keys.
| Feature | pi version | opencode version |
|---|---|---|
| Tool API | pi.registerTool + TypeBox |
tool() helper + zod |
| Model detection | model_select event |
session.updated event + live session.get |
| Toggle mechanism | pi.setActiveTools([...]) |
Runtime gate in execute() |
| Slash commands | /search, /search-status, etc. |
None (opencode has no plugin slash command API) |
| User notifications | ctx.ui.notify |
client.tui.showToast + client.app.log |
| Result truncation | Hardcoded 8000 | Configurable via WEB_SEARCH_MAX_CHARS |
opencode TUI window starts but is completely empty / hangs on startup.
This occurs if a plugin blocks the bootstrap loading sequence by awaiting async calls (like client.session.list()) during plugin registration before the session manager is ready. Ensure you are using the latest version of web-search.ts where session pre-fetching is performed as a non-blocking background promise.
Tool never runs / returns the "hosted model" message on a local model.
Your provider ID doesn't match any pattern. Check opencode logs for provider: <value> and add it to LOCAL_PATTERNS in the plugin file, or set WEB_SEARCH_FORCE=on.
Brave API 401: ...
Wrong or expired BRAVE_API_KEY. Re-check the key at https://brave.com/search/api/.
Both backends fail.
Verify at least one key is set. The plugin logs brave=false serper=false on startup if neither is configured.
No toast on model switch. You're likely running in non-TUI mode (SDK / server). Toasts require the TUI — logs still capture the transition.
MIT © 2026 mypbs