your local web intel layer
oxe is a local web-search proxy with an Exa.ai-compatible HTTP API and an MCP server, backed by DuckDuckGo. Built so AI agents (Claude Code, Cursor, Hermes, and anything that speaks HTTP or MCP) can search the web without burning third-party API quotas, and so you can search from a browser against the same cache. Single Python process, ~22 MB RSS (~56 MB with the ai extra), SQLite TTL cache, no API keys, no telemetry.
Want your agent to set it up? Paste this into it:
oxe: local web search for AI agents (Exa-compatible API + MCP, DuckDuckGo-backed, no API quotas).
Install: uv tool install oxe
Optional AI answers: uv tool install "oxe[ai]" (then set provider/model in the web UI settings at http://127.0.0.1:4479)
Prompt for your agent:
"I have oxe running locally at http://127.0.0.1:4479. Use its MCP server at http://127.0.0.1:4479/mcp/ (tools: exa_search, exa_user_history) instead of paid search APIs."
Agents: this document is the full context. Raw plaintext for cheap loading:
https://raw.githubusercontent.com/espetro/oxe/refs/heads/main/README.md
Install and run
uv tool install oxe # core
uv tool install "oxe[ai]" # with AI answers (aisuite + provider SDKs)
oxe # binds to 127.0.0.1:4479
curl http://127.0.0.1:4479/health
Open http://127.0.0.1:4479/ for the web UI (search, AI answers, history, settings). Other install paths (pinned from GitHub, mise, editable from source) and service setup (systemd, launchd) are covered further down.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/search |
Exa-compatible search (JSON in, JSON out). X-Cache: HIT/MISS response header. Backend failure is signaled with _error and _error_kind fields instead of a cached empty result. |
GET |
/search?q=X |
HTML results page for browsers, Exa JSON with Accept: application/json |
GET |
/ |
SPA web UI from the built bundle (see OXE_UI_DIST) |
GET |
/dashboard |
SPA dashboard shell (usage metrics, cache health) |
GET |
/api/history |
merged click history view (UI + API) |
GET |
/api/stats |
dashboard metrics: searches, hit rate, latency, clients |
GET |
/suggest?q=X |
OpenSearch suggestions JSON (own query history + completions) |
GET |
/ac?q=X |
DuckDuckGo autocomplete proxy (UI suggestions) |
POST |
/answer |
SSE-streamed AI answer grounded in a live search (requires oxe[ai]) |
GET |
/v1/models |
configured provider's model list (OpenAI-compatible; empty when unconfigured) |
GET / PUT |
/settings |
read / write the [ai] config section (api_key redacted on read, preserved when omitted on write) |
POST |
/settings/test |
test the configured AI provider |
GET |
/health |
liveness + cache stats |
GET |
/cache/stats |
cache row count, hit total, db size |
POST |
/cache/invalidate |
wipe all cached rows |
GET |
/row/{hash} |
redirect to /search?q=<original query> |
POST |
/row/{key}/delete |
delete a cache row (204 idempotent for non-HTML clients, 303 for browsers) |
GET |
/history |
click history |
POST |
/history/delete |
delete click history (`scope=24h |
POST |
/click |
record a click from the web UI (sendBeacon) |
GET |
/mcp/ |
StreamableHTTP MCP transport |
GET |
/docs |
OpenAPI schema for the typed endpoints |
Errors use a JSON envelope ({"error": {...}}); search payload errors additionally carry _error / _error_kind so agents can distinguish "no results" from "backend down".
Quick search
curl -s -X POST http://127.0.0.1:4479/search \
-H 'Content-Type: application/json' \
-d '{"query":"python asyncio","numResults":3,"contents":{"text":true,"highlights":true}}' \
| jq '.results[].title'
Browsers can use the same URL: http://127.0.0.1:4479/search?q=python+asyncio renders the results page from the same cache. Register it as a browser search engine with http://127.0.0.1:4479/search?q=%s.
MCP
Two tools on the StreamableHTTP transport at http://127.0.0.1:4479/mcp/, sharing the same SQLite cache as HTTP:
exa_search(query, num_results=10, type="auto", contents_highlights=true, contents_text=true, include_domains=[], exclude_domains=[], category="")— Exa-shaped search response.exa_user_history(query="", query_hash="", limit=20, since_hours=168)— recent URLs the user opened from the web UI for a query. Call it before searching to avoid re-researching what was already explored.
Agents can record their own exploration with POST /click ({query_hash, result_id, url, title, source: "mcp"}) so it shows up in the user's history.
Handshake smoke test:
curl -s http://127.0.0.1:4479/mcp/ -X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}' | head
Configuration
All env vars are optional:
| Var | Default | Effect |
|---|---|---|
OXE_PORT |
4479 |
bind port (always 127.0.0.1) |
OXE_CACHE_DIR |
~/.cache/oxe |
SQLite directory |
OXE_TTL_DEFAULT |
3600 |
TTL for non-empty results (s) |
OXE_TTL_MAX |
86400 |
TTL ceiling (s) |
OXE_NEGATIVE_TTL |
300 |
TTL for empty results (s) |
OXE_CLICK_RETENTION_DAYS |
30 |
how long to keep click history |
OXE_SEARCH_LOG_RETENTION_DAYS |
30 |
how long to keep search_log rows (feeds /api/stats) |
OXE_BACKENDS |
unset | JSON backend spec, see Backends. Unset means DuckDuckGo. |
OXE_LOG_LEVEL |
INFO |
log level |
OXE_UI_DIST |
unset | built SPA directory. Resolution order: this var, ./ui/dist (repo checkout), packaged oxe/ui_dist. Without any, / serves a minimal page explaining how to get the UI; the JSON API and MCP work regardless. |
OXE_CONFIG_DIR |
~/.config/oxe |
directory holding config.toml (AI settings) |
OXE_DEV |
unset | 1 = structured JSON dev observability events on stdout |
Web UI
The UI is a Preact SPA (Vite, daisyUI, self-hosted fonts) served from the built bundle. It has a search view with an AI answer mode, a history page backed by /api/history, a dashboard backed by /api/stats, and a settings dialog that writes the same config.toml as PUT /settings. AI answers need oxe[ai] plus a provider and model, set in the settings dialog or in $OXE_CONFIG_DIR/config.toml:
[ai]
provider = "openai"
model = "gpt-4o-mini"
api_key = "{env.OPENAI_API_KEY}" # {env.NAME} interpolation; a .env file in the cwd is loaded first
enabled = true
AI mode is off unless both provider and model are set. Providers: openai, anthropic, groq, mistral, ollama, huggingface.
Backends
DuckDuckGo is the default, but the backend layer is pluggable. oxe can drive any engine the bundled ddgs library supports (bing, brave, google, mojeek, yahoo, yandex, wikipedia, and more), plus third-party backends registered under the oxe.backends entry-point group.
OXE_BACKENDS='"mojeek"' oxe # single engine
OXE_BACKENDS='["ddg", "brave"]' oxe # fallback chain
OXE_BACKENDS='{"mode": "fanout", "backends": ["ddg", "google"]}' oxe # concurrent merge + dedupe
A string is one engine, a list is a fallback chain, an object is a compositor (mode: fallback or fanout). The cache key includes the backend name, so engines never collide in one database.
Engine reliability varies by network and time of day; some engines throttle datacenter or VPN IPs hard. Prefer fallback chains (["ddg", "bing", "mojeek"]) so a throttled engine falls through to a healthy one.
Python equivalent:
import oxe
backend = oxe.build_from_env() # or oxe.registry.resolve(["ddg", "brave"])
app = oxe.make_app(backend=backend)
Custom backends implement the SearchBackend protocol (oxe.backends) and register an oxe.backends entry point; they are discovered by name automatically.
Exa to DuckDuckGo translation notes
DuckDuckGo does not support deep-search variants, summaries, system prompts, or output schemas. Pass-through fields are silently ignored with a server log warning. publishedDate, author, and image are always null for DDG results. text and highlights are populated only when contents.text=true and contents.highlights=true are requested.
Architecture
┌────────────┐ POST /search ┌──────────────────────────────┐
│ HTTP CLI │ ─────────────────▶ │ │
└────────────┘ │ oxe (FastAPI + uvicorn) │
│ │
┌────────────┐ MCP /mcp/ │ ┌────────────────────────┐ │
│ Claude / │ ─────────────────▶ │ │ SQLite (WAL) cache │ │
│ Hermes / │ │ │ + ddgs (DuckDuckGo) │ │
│ Cursor │ │ └────────────────────────┘ │
└────────────┘ │ │
│ Preact SPA (ui/dist) │
┌────────────┐ browser │ + SSE /answer streaming │
│ You, via │ ─────▶ / ────────▶│ + sendBeacon /click │
│ browser │ └──────────────────────────────┘
└────────────┘
oxe/cache.py— SQLite WAL, gzip values, TTL eviction.oxe/exa_compat.py— Exa request/response toddgstranslation.oxe/search.py— shareddo_search(cache, req)used by HTTP and MCP.oxe/server/— FastAPI app split into APIRouters per resource group.oxe/mcp_server.py— MCP server with the two tools above.oxe/ai.py— provider-backed answer generation with a tool loop over search.ui/workspace (not packaged) — Preact SPA; built bundle served viaOXE_UI_DIST.
Memory and cost
- RSS: ~22 MB without the
aiextra, ~56 MB withoxe[ai](aisuite imports provider SDKs lazily). 200 MB ceiling. - Disk:
~/.cache/oxe/cache.dbtypically under 10 MB; WAL file under 5 MB. - Network: one DuckDuckGo request per unique cache miss. Cached responses replay instantly.
- Third-party services: none. No API keys, no telemetry.
Use as a Python library
from oxe.cache import TTLCache
from oxe.search import do_search
cache = TTLCache("/tmp/my-cache.db")
hits = do_search(cache, {"query": "python asyncio", "numResults": 5})
# Or build a standalone app with your own cache/backend/observer:
app = oxe.make_app(cache=cache)
| Symbol | Where | Use it for |
|---|---|---|
TTLCache(db_path) |
oxe.cache |
Reusable SQLite cache with TTL eviction, click history, stats. Threadsafe (WAL + lock). |
do_search(cache, req_dict, ttl=None) |
oxe.search |
The single canonical search path, shared by HTTP and MCP. |
exa_compat.search(req) |
oxe.exa_compat |
Lower-level Exa to DDGS translation (no cache). |
SearchBackend / CacheAdapter / DDGBackend |
oxe.backends |
Protocols for plugging in your own backend or cache; DDGBackend is the default, DdgsBackend(engine) drives any ddgs-supported engine. |
make_app(cache=None, backend=None, on_result=None) |
oxe.server |
Build the FastAPI app with your own cache/backend/observer; wrap in uvicorn or mount under another app. |
Configuration is via OXE_* env vars (table above).
Run as a service
systemd
# ~/.config/systemd/user/oxe.service
[Unit]
Description=oxe web-search proxy
After=network.target
[Service]
ExecStart=/home/you/.local/bin/oxe
Restart=on-failure
Environment=OXE_PORT=4479
[Install]
WantedBy=default.target
systemctl --user enable --now oxe
launchd (macOS)
<!-- ~/Library/LaunchAgents/local.oxe.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>local.oxe</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.local/bin/oxe</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>OXE_PORT</key><string>4479</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/local.oxe.plist
Multi-device setups
Do not point multiple devices at the same cache.db on a network filesystem. SQLite in WAL mode is explicitly documented as not safe over NFS or SMB:
"WAL does not work over a network filesystem. This is because WAL requires all processes to share a small amount of memory and processes on separate host machines obviously cannot share memory with each other." — sqlite.org/wal.html
Three patterns that work:
- Run one oxe on your router or a home server, every device hits it. Simplest and recommended:
http://router.lan:4479/searchfrom everywhere. - Per-device cache with Litestream replication to S3/R2 if you want a mergeable, disaster-recoverable history.
- One central writer, many readers via the HTTP API. Devices never touch SQLite directly; they POST to the central oxe.
Note that oxe does not require auth today: binding it to a shared interface means anyone on that network can search through you. Run it behind a reverse proxy with a bearer token, or on a trusted LAN only.
Browser-friendly URLs (portless)
http://127.0.0.1:4479/ is fine most of the time. A real hostname with TLS is nicer for per-origin browser permissions, bookmarkable history pages, and MCP clients anywhere on your LAN.
With portless installed, pin a hostname to the port oxe already listens on (one-time; the alias survives restarts):
portless alias search 4479
curl https://search.localhost/health
Then register https://search.localhost/search?q=%s as a browser search engine. If you would rather let portless supervise the process, use OXE_PORT=4479 portless run --name search --app-port 4479 oxe (flags before the command; oxe reads OXE_PORT, not the generic PORT). To undo the alias: portless alias --remove search.
Observability
The live process exposes GET /health (liveness, version, cache rows), GET /cache/stats, GET /api/history, and GET /api/stats (searches, cache hit rate, latency, client split). With OXE_DEV=1 it emits structured JSON events on stdout.
Every search (HTTP, MCP, web UI) also writes a row to the search_log SQLite table: query text + hash, cache vs network, result count, latency in ms, and the client. Rows are pruned to OXE_SEARCH_LOG_RETENTION_DAYS (default 30).
For a shareable static report, build the dashboard:
oxe stats --db ~/.cache/oxe/cache.db --out ./dist/dashboard --days 30
Emits a single self-contained index.html (six inline-SVG panels, no JavaScript). Opens SQLite read-only, so it is safe against the live cache.
Machine context for AI agents
Context for AI coding assistants working with or against oxe:
- Project role: local web-search proxy. Exa-compatible
POST /search,GET /search?q=with content negotiation, StreamableHTTP MCP at/mcp/.- Key invariants: the SQLite cache is shared by HTTP and MCP; cache keys include query, page, and backend name. Empty results are cached with a short negative TTL; backend failures are NOT cached and return
_error/_error_kindfields plus a non-2xx-tolerant payload check.- Search errors vs empty results:
_error_kindvalues includebackend_error. Treat presence of_erroras retryable; absence with emptyresultsas genuinely empty.- Click tracking: record agent-driven exploration via
POST /clickwithsource: "mcp"; read what the user already explored via theexa_user_historyMCP tool before searching.- Config: AI settings live in
$OXE_CONFIG_DIR/config.tomlunder[ai];PUT /settingsis the supported write path (omitapi_keyto keep the stored value). String values support{env.NAME}interpolation.- Testing:
mise run test(Python),mise run checkfor lint/typecheck gates. Do not write tests that hit live DuckDuckGo.
How is this different from X?
| Feature | oxe | ddgs direct |
MCP-server competitors |
|---|---|---|---|
| Exa-compatible HTTP API | ✅ | ❌ | ❌ |
| MCP server | ✅ | ❌ | ✅ |
| SQLite TTL cache | ✅ | ❌ | ❌ |
| Click-history tool | ✅ | ❌ | ❌ |
| Web UI + AI answers | ✅ | ❌ | ❌ |
| Python library | ✅ | ✅ (low-level) | ❌ |
| Single process, no API keys | ✅ | n/a | ❌ |
Dependencies
Runtime: ddgs, fastapi, uvicorn, pydantic, mcp, aiosql. All pulled by uv tool install oxe. No system-level dependencies. Optional extras: oxe[ai] for AI answers, oxe[fuzzy] for higher-quality fuzzy matching in history search (falls back to stdlib difflib without it).
The logotype is set in Apfel Grotesk (OFL), converted to outlines in assets/logotype.svg.
License
Metadata
Release files for oxe 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| oxe-0.4.0.tar.gz | 76.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| oxe-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 138.0 kB
Release files / oxe-0.4.0.tar.gz
| Download URL | oxe-0.4.0.tar.gz |
|---|---|
| Size | 76.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
150dbcb64ed9b8de4fd88c4ac5c5fbd2a9c8f6b1a19636a2f700a1d06b4a6cff
|
|
BLAKE2b-256 checksum How to use checksums |
7420280e8c33f50cb79cbf86e2d357d32867eea470a481687130df331485945a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / oxe-0.4.0-py3-none-any.whl
| Download URL | oxe-0.4.0-py3-none-any.whl |
|---|---|
| Size | 61.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fffef43e89813721cf08baa0b2b9f12ed0a98aff442ec30c2a77ac52d1749fd5
|
|
BLAKE2b-256 checksum How to use checksums |
2c6b40170325cebe6809faf92ad6c1f69fb2fa4f54961ff7775debc91146356d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log