oxe
Local web-search proxy and cache for AI agents. Exa.ai-compatible HTTP API, StreamableHTTP MCP, DuckDuckGo backend, SQLite TTL cache. ~70 MB RSS. One Python process. No API keys.
uv tool install oxe
oxe
Why oxe?
- Don't burn your Exa/Brave/Serper free tier. Same Exa-shaped HTTP API, served from your own machine, backed by DuckDuckGo. Cached responses are shared between HTTP and MCP — second agent hits
/searchforpython asyncio? It's instant. - Two surfaces, one cache.
POST /searchfor HTTP clients;/mcp/StreamableHTTP transport for Claude Code, Cursor, Hermes, or any MCP-aware agent. Both pull from the same SQLite TTL cache. - Your agents see what you explored. Click a result in the web UI; the URL is logged with
exa_user_historyso future agents know what's already been read. - Tiny footprint. ~70 MB steady-state RAM, single
uvicornworker. Runs on a Raspberry Pi.
Install
One-liner (PyPI)
uv tool install oxe
Pinned from GitHub
uv tool install "git+https://github.com/espetro/oxe@v0.1.0"
With mise
# mise.toml
[tools]
"pypi:oxe" = "latest"
From source
git clone https://github.com/espetro/oxe
cd oxe
uv tool install -e .
Run
oxe # foreground
curl http://127.0.0.1:4479/health # {"status":"ok",...}
Binds to 127.0.0.1:4479 by default. Override with OXE_PORT=8080 oxe.
Open http://127.0.0.1:4479/ for the search UI.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/search |
Exa-compatible search (JSON in, JSON out) |
GET |
/health |
liveness + cache stats |
GET |
/cache/stats |
cache row count, hits, db size |
POST |
/cache/invalidate |
wipe all cached rows |
GET |
/ |
server-rendered HTML search UI |
GET |
/history |
click history |
POST |
/click |
record a click (called by the UI) |
GET |
/mcp/ |
StreamableHTTP MCP transport |
GET |
/docs |
FastAPI auto-generated OpenAPI |
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'
MCP handshake
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
MCP tools
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 you opened from the web UI for a given query. Call this BEFORE searching if you want to avoid re-researching what you already explored.
Both tools share the same SQLite cache as the HTTP endpoint.
Configuration
All optional. Override via env vars:
| Var | Default | Effect |
|---|---|---|
OXE_PORT |
4479 |
bind port |
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_LOG_LEVEL |
INFO |
log level |
Exa → 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. The following Exa fields are always
null for DDG results because DDG doesn't expose them:
publishedDateauthorimage
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 │ │ └────────────────────────┘ │
└────────────┘ │ │
│ static/app.js (vanilla JS) │
┌────────────┐ browser │ + <template> result cards │
│ You, via │ ─────▶ / ────────▶│ + sendBeacon /click │
│ browser │ └──────────────────────────────┘
└────────────┘
oxe/cache.py— SQLite WAL, gzip values, TTL eviction.oxe/exa_compat.py— Exa request/response ↔ddgstranslation.oxe/search.py— shareddo_search(cache, req)used by HTTP and MCP.oxe/server.py— FastAPI app, all routes, startup click pruner.oxe/mcp_server.py—MCPServerwith two tools.oxe/ui.py+oxe/static/{app.js,ui.css}— stdlib-rendered HTML, vanilla JS.
Memory & cost
- RSS: ~70 MB cold-start, ~27-35 MB steady-state. 200 MB ceiling.
- Disk:
~/.cache/oxe/cache.dbtypically <10 MB. WAL file<5 MB. - Network: one DuckDuckGo HTML request per unique cache miss. Cached responses replay instantly.
- Third-party services: none. No API keys, no telemetry.
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
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 | ✅ | ❌ | ❌ |
| Single process | ✅ | n/a | ✅ |
| External API key | ❌ | ❌ | ❌ |
| Web UI | ✅ | ❌ | ❌ |
Dependencies
Runtime: ddgs, fastapi, uvicorn, pydantic, mcp. All pulled by uv tool install oxe automatically. No system-level dependencies.
Optional host tools (not required): portless for https://*.localhost/ URLs, oxmgr / systemd / launchd for supervision.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file oxe-0.1.1.tar.gz.
File metadata
- Download URL: oxe-0.1.1.tar.gz
- Upload date:
- Size: 25.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
53b900c9fc0627c43478580f9d5f81ee2454ba83ffcfd9a572442097db507cf6
|
|
| MD5 |
042a6cb2662e09e9cbb9ad716ee6afc8
|
|
| BLAKE2b-256 |
2fbe793318744170a7bda3fab9fcc11dcf3c322af4c4c335251663d28dda6857
|
Provenance
The following attestation bundles were made for oxe-0.1.1.tar.gz:
Publisher:
publish-pypi.yml on espetro/oxe
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
oxe-0.1.1.tar.gz -
Subject digest:
53b900c9fc0627c43478580f9d5f81ee2454ba83ffcfd9a572442097db507cf6 - Sigstore transparency entry: 2833429134
- Sigstore integration time:
-
Permalink:
espetro/oxe@1ea22eb83289c0f3397955b52e375354e21e722d -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/espetro
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@1ea22eb83289c0f3397955b52e375354e21e722d -
Trigger Event:
push
-
Statement type:
File details
Details for the file oxe-0.1.1-py3-none-any.whl.
File metadata
- Download URL: oxe-0.1.1-py3-none-any.whl
- Upload date:
- Size: 23.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
18b61fd88ecd1c4ed6aed5029fac65e0a76b7cfde59d3837fb43f766d343c28e
|
|
| MD5 |
e92901c4552790842980fdc09f2a783a
|
|
| BLAKE2b-256 |
425b66a1c65f0cc90f4a6004e1b1030d600a9e27839d65ba220a8369ced29425
|
Provenance
The following attestation bundles were made for oxe-0.1.1-py3-none-any.whl:
Publisher:
publish-pypi.yml on espetro/oxe
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
oxe-0.1.1-py3-none-any.whl -
Subject digest:
18b61fd88ecd1c4ed6aed5029fac65e0a76b7cfde59d3837fb43f766d343c28e - Sigstore transparency entry: 2833429148
- Sigstore integration time:
-
Permalink:
espetro/oxe@1ea22eb83289c0f3397955b52e375354e21e722d -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/espetro
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@1ea22eb83289c0f3397955b52e375354e21e722d -
Trigger Event:
push
-
Statement type: