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.1"
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, or hit it as https://search.localhost/ if you run it under portless (see Browser-friendly URLs).
Browser-friendly URLs
Most of the time http://127.0.0.1:4479/ is fine. But three things are nicer with a real hostname + TLS:
- Browsers let you grant microphone / clipboard / persistent-storage per-origin. A trusted hostname (
https://search.localhost/) makes per-site permissions stick across tabs, and lets you bookmark/historycleanly. - Your MCP agent talks to the same URL from anywhere on your LAN. Hermes, Claude Code, and Cursor all accept
https://search.localhost/mcp/as a transport once you've set it up once. - HTTPS solves the MCP
isahc/curl clients that ignore Mac Keychain. Without it you'll get opaque TLS errors when an MCP client (maki, Hermes) calls your local oxe.
Install portless (brew install portless or follow the docs). oxe reads OXE_PORT (default 4479), not the generic PORT env var that portless sets for the proxied process, so the cleanest pairing is the static-alias path: let your supervisor (oxmgr / systemd / launchd) own the port, then point portless at it.
# One-time: pin a hostname to the port oxe is already listening on.
portless alias search 4479
curl https://search.localhost/health # {"status":"ok", ...}
If you'd rather let portless supervise the process itself, pick a port up front so the route stays stable, and stop your supervisor first so portless can bind 4479:
# 1. stop whatever is already on 4479 (oxmgr / systemd / launchd):
oxmgr stop oxe # or: sudo systemctl stop oxe / launchctl unload ~/Library/LaunchAgents/oxe.plist
# 2. hand the port to portless and let it spawn oxe:
OXE_PORT=4479 nohup portless oxe oxe --name search --app-port 4479 >/tmp/oxe.log 2>&1 &
portless list # -> https://search.localhost -> localhost:4479 (portless-managed)
Open https://search.localhost/ in any browser — the cert is automatically trusted (portless manages its own local CA). Use portless list to confirm the route, portless doctor to debug.
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
Use as a Python library
oxe is more than a CLI: every internal seam is a normal Python import.
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})
Useful entry points:
| 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↔DDGS translation (no cache). |
build_app() / app |
oxe.server |
The FastAPI app — ready to wrap in uvicorn or mount under another app. |
Configuration is via env vars (OXE_*, listed below). For non-trivial embedding, instantiate TTLCache(...) yourself and pass it where you need it; the global module-level instance in oxe.server is only used by the bundled CLI.
Multi-device setups
The most common "I want this everywhere" question is whether you can install oxe on every device on your network and point them at the same cache file on the router. Don't. 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
"the network link ... in the File I/O channel, transactions may fail ... but with the additional effect that the remote database is corrupted." — sqlite.org/useovernet.html
Real-world post-mortems (e.g. "the SQLite trap that corrupted my S3 metadata", 2026) confirm that NFSv4 lock delegation silently fails under concurrent reader + writer and corrupts the database.
Three patterns that do work:
- Run one oxe on your router, every device hits it. Simplest and recommended. Set
OXE_BIND=0.0.0.0(currently127.0.0.1only — seeoxe/__main__.py), expose4479, and have devices usehttp://router.lan:4479/search. Optionally put portless on the same box and gethttps://search.localhost/everywhere. - Per-device cache + Litestream replication to a single S3/R2 bucket. Each box has its own local SQLite;
litestream replicate ~/.cache/oxe/cache.db s3://bucket/$HOSTNAME.dbruns alongside oxe. Disaster recovery + a shared history you can merge from on boot. Adds a tiny Go binary per box. - One central writer, many readers via oxe's HTTP API. Same as (1) but framed deliberately — devices never touch SQLite directly, they POST to the central oxe.
For the LAN case, expose the port with care: oxe does not require auth today, so binding it to a public-facing 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.
Observability
Today the proxy exposes:
GET /health— liveness, version, cache row count.GET /cache/stats— rows, unexpired rows, hits total, db size on disk, oldest/newest.GET /cache,GET /history— human-readable listings rendered server-side.
Missing (planned for 0.2.x):
- Per-backend hit ratio (
cachevsddgs) over time. - Top queries by hits / by recency.
- Top domains returned across the cache.
- Click-through rate (rows x clicks).
All of these can be answered with one new SQLite table (search_log(ts, query_hash, backend, duration_ms, num_results)) and zero new runtime deps. They're intentionally not in 0.1.x to keep the live process CPU-light — the dashboards that surface them should be rendered as static HTML/SSG (no per-request DB scans), not as additional SSR routes.
Idea for the dashboard (sketch, not implemented yet): a stdlib-only python -m oxe stats build subcommand that reads the SQLite file read-only with a separate connection and emits dist/dashboard/*.html + inline SVG charts. Total new code: ~150 lines, no node_modules. See oxe/ui.py for the existing template helpers it would reuse. Tools evaluated and rejected as overbuilt for this: Astro 7, Evidence.dev, Docusaurus.
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 | ✅ | ❌ | ❌ |
| Python library | ✅ (see Use as a library) | ✅ (low-level) | ❌ |
| Multi-device via LAN | ✅ (run-on-router, see Multi-device) | manual | manual |
| 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 (recommended for browser + TLS), 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.3.tar.gz.
File metadata
- Download URL: oxe-0.1.3.tar.gz
- Upload date:
- Size: 31.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c40d63a2863d259e82f4c2fa06e600afdaa91dacdede2ba55c256bbdc8284213
|
|
| MD5 |
cbdc4056b96ef2341b45cedacb0bca7e
|
|
| BLAKE2b-256 |
eac9aaef57c7348bb433d97e5a7239fa461714ab70710c86742df7146bca4b9f
|
Provenance
The following attestation bundles were made for oxe-0.1.3.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.3.tar.gz -
Subject digest:
c40d63a2863d259e82f4c2fa06e600afdaa91dacdede2ba55c256bbdc8284213 - Sigstore transparency entry: 2833570062
- Sigstore integration time:
-
Permalink:
espetro/oxe@b0cff0e7c48c7ea90ddad4d3e7758c6ef7dfb06c -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/espetro
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@b0cff0e7c48c7ea90ddad4d3e7758c6ef7dfb06c -
Trigger Event:
push
-
Statement type:
File details
Details for the file oxe-0.1.3-py3-none-any.whl.
File metadata
- Download URL: oxe-0.1.3-py3-none-any.whl
- Upload date:
- Size: 26.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 |
03b01ff11ed3bedbae8dcb3958631a0ef87117b387d174ca29b83a6e79b68edc
|
|
| MD5 |
3052010e9199d5065f185be5f78a08e1
|
|
| BLAKE2b-256 |
9593b7de4c974a05d21e3a17ab0dc46133bf7ea11a680f192b89f518de1a86c6
|
Provenance
The following attestation bundles were made for oxe-0.1.3-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.3-py3-none-any.whl -
Subject digest:
03b01ff11ed3bedbae8dcb3958631a0ef87117b387d174ca29b83a6e79b68edc - Sigstore transparency entry: 2833570100
- Sigstore integration time:
-
Permalink:
espetro/oxe@b0cff0e7c48c7ea90ddad4d3e7758c6ef7dfb06c -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/espetro
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@b0cff0e7c48c7ea90ddad4d3e7758c6ef7dfb06c -
Trigger Event:
push
-
Statement type: