Skill: prompt-surfer/exa-search-rust (version 1.0.3)

--- name: exa-search description: "Neural web search, similar-page discovery, and URL content fetching via the Exa AI search engine. USE WHEN: user asks to search the web, find articles/repos/papers on a topic, look up recent news, find pages similar to a URL, or fetch page contents for a known URL. NOT FOR: fetching tweets (use fxtwitter), downloading videos (use yt-dlp), scraping JS-heavy or anti-bot sites (use scrapling), querying APIs directly, or searching local files/repos (use rg/find)." homepage: https://github.com/Prompt-Surfer/exa-search-rust metadata: {"openclaw":{"emoji":"🔍","requires":{"env":["EXA_API_KEY"],"bins":["bash","cargo"]},"primaryEnv":"EXA_API_KEY","install":[{"id":"build","kind":"script","label":"Build Rust binary","run":"bash install.sh"}]}} --- # exa-search skill Use this skill to search the web, find similar pages, or fetch page contents via the [Exa AI](https://exa.ai) search engine — fast, neural, and certificate-aware. The skill invokes a native Rust binary (`bin/exa-search`) via Bash. Run `install.sh` once to build it. --- ## When to Use / When NOT to Use ✅ **USE when:** - Searching the web for articles, documentation, repos, papers, tools, people, companies - Finding recent news or announcements (use `livecrawl: "fallback"` or `"always"` for recency) - Fetching full text of a known URL without browser automation - Finding pages similar to a reference URL (competitor analysis, alternative tools) - Any web lookup that isn't a specific tweet or video ❌ **NOT FOR:** - Fetching tweets/X posts → use `fxtwitter` skill (Exa can't fetch tweet URLs) - Downloading video/audio → use `yt-dlp` - Scraping dynamic or Cloudflare-protected pages → use `scrapling` - Local file/code search → use `rg`, `find`, or `grep` - Querying structured APIs (GitHub, weather, etc.) → use their dedicated skills - When the query is already a direct URL with known content → prefer `get_contents` action --- ## Prerequisites - `EXA_API_KEY` set in `~/.openclaw/workspace/.env` (get one at [exa.ai](https://exa.ai)) - Rust installed (`rustup`) — only needed for the one-time build - `bin/exa-search` binary present (run `bash install.sh` to build) --- ## Actions ### 1. Search ```bash echo '{"query":"your query here","num_results":5}' \ | EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') \ ~/.openclaw/workspace/skills/exa-search/bin/exa-search ``` **Full params:** ```json { "query": "rust async programming", "num_results": 5, "type": "neural", "livecrawl": "never", "include_domains": ["github.com", "docs.rs"], "exclude_domains": ["reddit.com"], "start_published_date": "2025-01-01", "end_published_date": "2026-12-31", "category": "research paper", "use_autoprompt": true, "text": { "max_characters": 2000 }, "highlights": { "num_sentences": 3, "highlights_per_url": 2 }, "summary": { "query": "key takeaways" } } ``` **`type` options:** `auto` (default) · `neural` · `keyword` **`livecrawl` options:** - `"never"` — fastest (~300-600ms), pure cached index. Best for reference material, docs, courses. - `"fallback"` — use cache, crawl live if not cached. Good default. - `"preferred"` — prefer live crawl. Slower but fresher. - `"always"` — always crawl live. For breaking news or rapidly-changing pages. --- ### 2. Find Similar Find pages similar to a given URL: ```bash echo '{"action":"find_similar","url":"https://example.com","num_results":5}' \ | EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') \ ~/.openclaw/workspace/skills/exa-search/bin/exa-search ``` **Params:** same contents options as search (`text`, `highlights`, `summary`, `livecrawl`) --- ### 3. Get Contents Fetch full contents for one or more URLs: ```bash echo '{"action":"get_contents","urls":["https://example.com","https://other.com"],"text":{"max_characters":1000}}' \ | EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') \ ~/.openclaw/workspace/skills/exa-search/bin/exa-search ``` --- ## Output format All actions return JSON on stdout: ```json { "ok": true, "action": "search", "results": [ { "url": "https://...", "title": "...", "score": 0.87, "author": "...", "published_date": "2026-01-15", "image": "https://...", "favicon": "https://...", "text": "...", "highlights": ["..."], "summary": "..." } ], "formatted": "## [Title](url)\n..." } ``` On error: ```json { "ok": false, "error": "..." } ``` The `formatted` field is ready-to-use markdown — you can send it directly to the user. --- ## Speed reference (same query, 3 runs) | Mode | Avg | Peak | |---|---|---| | `livecrawl: "never"` (instant) | ~440ms | **308ms** | | Default (no livecrawl) | ~927ms | 629ms | ~18.7× faster than Exa MCP at peak. --- ## Helper: load API key ```bash EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') ``` Or export once at the top of a longer workflow: ```bash export EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') echo '{"query":"..."}' | ~/.openclaw/workspace/skills/exa-search/bin/exa-search ``` --- ## Invocation pattern ```bash EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') echo '{"query":"...","num_results":5,"livecrawl":"never"}' \ | EXA_API_KEY="$EXA_API_KEY" ~/.openclaw/workspace/skills/exa-search/bin/exa-search ``` The `formatted` field in the output is ready-to-use markdown — send it directly to the user. --- ## Mode selection (be deliberate, every search) | Situation | `livecrawl` | `type` | |---|---|---| | Docs, tutorials, courses, reference material | `"never"` | `"neural"` | | General research — people, tools, concepts, companies | `"never"` | `"neural"` | | Exact function names, error messages, package names | `"never"` | `"keyword"` | | Recent releases, changelogs, GitHub repos | `"fallback"` | `"auto"` | | News or announcements from the last 1-2 weeks | `"fallback"` | `"neural"` | | Breaking news, live prices, today's events | `"always"` | `"neural"` | | Unsure | `"fallback"` | `"auto"` | **Default when in doubt:** `livecrawl: "never"`, `type: "neural"` — fastest, works for 80% of searches. --- ## When to use each action **`search`** (default) — use for any information retrieval from a query string. **`find_similar`** — use when you have a URL and want more like it: related articles, alternative tools, similar repos, competing products. ```bash echo '{"action":"find_similar","url":"https://...","num_results":5,"livecrawl":"never"}' | EXA_API_KEY="$EXA_API_KEY" ... ``` **`get_contents`** — use when you have a specific URL and need its full text: docs pages, blog posts, GitHub READMEs, papers. Faster than `search` when the URL is already known. ```bash echo '{"action":"get_contents","urls":["https://..."],"text":{"max_characters":3000}}' | EXA_API_KEY="$EXA_API_KEY" ... ``` --- ## Enrich results for research tasks When writing reports, summaries, or comparing multiple results — request highlights or summary per result: ```json { "query": "...", "num_results": 5, "highlights": { "num_sentences": 3, "highlights_per_url": 2 }, "summary": { "query": "key takeaways for a developer" } } ``` Note: highlights/summary add latency (~200-500ms extra). Only use when you actually need them.

path: Cargo.toml
content: [package]
name = "exa-search"
version = "0.2.0"
edition = "2021"
description = "Native Rust Exa AI search binary for the OpenClaw exa-search plugin"
license = "MIT"
repository = "https://github.com/Prompt-Surfer/exa-search-rust"
keywords = ["exa", "search", "ai", "openclaw"]
categories = ["command-line-utilities"]

[[bin]]
name = "exa-search"
path = "src/main.rs"

[dependencies]
reqwest  = { version = "0.12", features = ["json"] }
tokio    = { version = "1", features = ["full"] }
serde    = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow   = "1"

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
unwrap_used = "warn"
expect_used = "warn"
dbg_macro = "warn"
todo = "warn"
multiple-crate-versions = "allow"


path: README.md
content: # exa-search-rust

An [OpenClaw](https://openclaw.ai) skill for [Exa AI](https://exa.ai) search, written in Rust. A port of the [official Exa Python SDK](https://github.com/exa-labs/exa-py) to a self-contained native binary — full core API coverage (neural search, find similar, page contents), hardened input validation, and blazingly fast 300ms searches.

> **Looking for the OpenClaw plugin version?** → [openclaw-exa-plugin](https://github.com/Prompt-Surfer/openclaw-exa-plugin) (registers `web_search_exa` as a native tool, no Bash required)

---

## Install

```bash
bash install.sh
```

Builds the Rust binary from [openclaw-exa-plugin](https://github.com/Prompt-Surfer/openclaw-exa-plugin) (local source or auto-cloned) and installs to `~/.openclaw/workspace/skills/exa-search/`.

Then add your API key to `~/.openclaw/workspace/.env`:

```bash
echo "EXA_API_KEY=your_key_here" >> ~/.openclaw/workspace/.env
```

---

## Usage

Agents read `SKILL.md` for full instructions. Quick reference:

```bash
# Search
echo '{"query":"rust async programming","num_results":5,"livecrawl":"never"}' \
  | EXA_API_KEY=$(grep -E "^EXA_API_KEY=" ~/.openclaw/workspace/.env | cut -d= -f2 | tr -d '"') \
  ~/.openclaw/workspace/skills/exa-search/bin/exa-search | jq .

# Find similar pages
echo '{"action":"find_similar","url":"https://doc.rust-lang.org","num_results":3}' \
  | EXA_API_KEY=... ~/.openclaw/workspace/skills/exa-search/bin/exa-search | jq .

# Fetch page contents
echo '{"action":"get_contents","urls":["https://example.com"]}' \
  | EXA_API_KEY=... ~/.openclaw/workspace/skills/exa-search/bin/exa-search | jq .
```

---

## Defaults & Configuration Reference

All fields are optional unless marked **required**. Omitting a field uses the default shown.

### Action routing

| Field | Type | Default | Description |
|---|---|---|---|
| `action` | `string` | `"search"` | `"search"` · `"find_similar"` · `"get_contents"` |

---

### Search (`action: "search"`)

| Field | Type | Default | Description |
|---|---|---|---|
| `query` | `string` | — | **Required.** What to search for |
| `num_results` | `integer` | `10` | Number of results (max `50`) |
| `type` | `string` | `"auto"` | `"auto"` · `"neural"` · `"keyword"` · `"fast"` · `"deep"` · `"instant"` |
| `use_autoprompt` | `bool` | `false` | Let Exa rewrite query for better neural results |
| `category` | `string` | none | `"research paper"` · `"news"` · `"tweet"` · `"github"` · etc. |
| `include_domains` | `string[]` | none | Restrict to these domains |
| `exclude_domains` | `string[]` | none | Block these domains |
| `start_published_date` | `string` | none | ISO 8601 — only results published after |
| `end_published_date` | `string` | none | ISO 8601 — only results published before |
| `start_crawl_date` | `string` | none | Filter by crawl date (not publish date) |
| `end_crawl_date` | `string` | none | Filter by crawl date |
| `include_text` | `string[]` | none | Results must contain these strings |
| `exclude_text` | `string[]` | none | Results must not contain these strings |
| `user_location` | `string` | none | ISO country code for localised results (e.g. `"AU"`) |
| `moderation` | `bool` | `false` | Enable Exa content moderation filter |
| `additional_queries` | `string[]` | none | Extra queries merged into the search |

---

### Find Similar (`action: "find_similar"`)

| Field | Type | Default | Description |
|---|---|---|---|
| `url` | `string` | — | **Required.** Seed URL to find similar pages for |
| `num_results` | `integer` | `10` | Number of results (max `50`) |
| `exclude_source_domain` | `bool` | `false` | Exclude results from the seed URL's domain |
| `category` | `string` | none | Same options as Search |
| `include_domains` | `string[]` | none | Same as Search |
| `exclude_domains` | `string[]` | none | Same as Search |
| `start_published_date` | `string` | none | Same as Search |
| `end_published_date` | `string` | none | Same as Search |

---

### Get Contents (`action: "get_contents"`)

| Field | Type | Default | Description |
|---|---|---|---|
| `urls` | `string[]` | — | **Required.** One or more URLs to fetch |

---

### Contents options (Search, Find Similar, Get Contents)

Control what content is returned with each result. Pass as a `contents` object, or use the top-level shorthands below.

#### `contents` object

| Field | Type | Default | Description |
|---|---|---|---|
| `text` | `object \| bool` | `{max_characters: 10000}` | Full page text. Set `false` to disable, or pass `{max_characters: N}` |
| `highlights` | `object \| bool` | off | Relevant excerpts. `{num_sentences: 3, highlights_per_url: 2}` |
| `summary` | `object \| bool` | off | AI summary. `{query: "key takeaways"}` or `true` for auto |
| `livecrawl` | `string` | `"never"` | `"never"` · `"fallback"` · `"preferred"` · `"always"` · `"auto"` |
| `livecrawl_timeout` | `integer` | none | Timeout in ms for live crawl requests |
| `filter_empty_results` | `bool` | `false` | Drop results with no text content |
| `subpages` | `integer` | none | Also fetch N subpages per URL |
| `subpage_target` | `string \| string[]` | none | Restrict subpage fetch to this path pattern |
| `extras.links` | `integer` | none | Return up to N extracted links per result |
| `max_age_hours` | `integer` | none | Skip cached content older than N hours |

#### `livecrawl` guidance

| Value | Speed | Use when |
|---|---|---|
| `"never"` | ~300–600ms ⚡ | Docs, reference material, anything stable |
| `"fallback"` | ~800–1200ms | Good general default — cache first, crawl if missing |
| `"preferred"` | ~1–2s | You want fresh content but cache is acceptable fallback |
| `"always"` | ~1–3s | Breaking news, rapidly-changing pages |

#### Top-level shorthands (alternative to `contents` object)

| Field | Type | Notes |
|---|---|---|
| `max_chars` | `integer` | Shorthand for `contents.text.max_characters` |
| `filter_empty_results` | `bool` | Same as `contents.filter_empty_results` |
| `extras` | `object` | Same as `contents.extras` |
| `max_age_hours` | `integer` | Same as `contents.max_age_hours` |

---

### Limits & validation

| Constraint | Value |
|---|---|
| Max `num_results` | 50 |
| Max stdin size | 1 MB |
| Max response body | 10 MB |
| API key format | UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) |

---

### Output

On success, stdout receives a JSON object with `ok: true`. On error, stderr receives `{"ok": false, "error": "..."}` and exit code is `1`.

**Search / find\_similar:**
```json
{
  "ok": true,
  "action": "search",
  "results": [...],
  "resolved_search_type": "neural",
  "search_time_ms": 412,
  "cost_dollars": "...",
  "formatted": "## [Title](url)\n\ntext\n\n---"
}
```

**Get contents:**
```json
{
  "ok": true,
  "action": "get_contents",
  "results": [...],
  "cost_dollars": "..."
}
```

---

## Benchmark

| Mode | Avg | Peak |
|---|---|---|
| Instant (`livecrawl: "never"`) | ~440ms | **308ms** |
| Default | ~927ms | 629ms |
| Exa MCP (`npx` cached) | ~5,747ms | — |

**Peak 308ms — 18.7× faster than MCP.** See [openclaw-exa-plugin](https://github.com/Prompt-Surfer/openclaw-exa-plugin#benchmark) for full benchmark table.

---

## Structure

```
.
├── SKILL.md          # Agent instructions — actions, params, output format
├── install.sh        # Build binary from bundled source + install to workspace
├── Cargo.toml        # Rust crate
├── src/              # Rust source — self-contained, no external repo needed
│   ├── main.rs
│   ├── client.rs
│   ├── protocol.rs
│   └── types/
└── bin/
    └── exa-search    # Pre-built Linux x86_64 binary (also buildable via install.sh)
```

---

## Roadmap

### ✅ Implemented (Python SDK parity)

| Feature | Python SDK | This repo |
|---|---|---|
| `search` | ✅ | ✅ |
| `find_similar` | ✅ | ✅ |
| `get_contents` | ✅ | ✅ |
| `text` contents | ✅ | ✅ |
| `highlights` contents | ✅ | ✅ |
| `summary` contents | ✅ | ✅ |
| `livecrawl` + `livecrawl_timeout` | ✅ | ✅ |
| `filter_empty_results` | ✅ | ✅ |
| `subpages` / `subpage_target` | ✅ | ✅ |
| `extras.links` | ✅ | ✅ |
| `max_age_hours` | ✅ | ✅ |
| Date filters (`start/end_published_date`, `start/end_crawl_date`) | ✅ | ✅ |
| Domain filters (`include/exclude_domains`) | ✅ | ✅ |
| Text filters (`include/exclude_text`) | ✅ | ✅ |
| `category` | ✅ | ✅ |
| `use_autoprompt` | ✅ | ✅ |
| `user_location` | ✅ | ✅ |
| `moderation` | ✅ | ✅ |
| `additional_queries` | ✅ | ✅ |
| `exclude_source_domain` (find\_similar) | ✅ | ✅ |

---

### 🗺️ Not Yet Implemented

| Feature | Notes |
|---|---|
| `answer` / `stream_answer` | Pay-per-use ($5/1k). Skipped — Claude + Perplexity already handle this. Could be added behind a flag. |
| `search_and_contents` / `find_similar_and_contents` | Convenience wrappers. Equivalent behaviour already available: pass a `contents` object to `search` or `find_similar`. |
| `image_links` | Excluded by design — adds noise in LLM contexts. |
| OpenAI-compatible wrapper (`wrap`) | Drop-in `openai.Client` replacement. Out of scope for an OpenClaw skill. |
| Websets client | Exa's structured data extraction product. Separate API surface. |
| Research client | Exa's deep research product. Separate API surface. |
| Entity types (`Company`, `Person`) for `find_similar` | Typed seed entities instead of URLs. Planned. |
| `context` contents | Deprecated in Exa Python SDK. Not planned. |

---

### 🔒 Added Beyond the Python SDK

These hardening features are not in the official Exa Python SDK:

| Feature | Description |
|---|---|
| **UUID API key validation** | Key is validated as lowercase hex UUID at startup — fails fast with a clear error, not a cryptic 401 |
| **1 MB stdin cap** | Prevents memory exhaustion from runaway input |
| **`num_results` hard cap (50)** | Mirrors Exa's server-side limit locally — rejects bad inputs before making a network call |
| **10 MB response body cap** | Prevents unbounded memory growth on large crawled pages |
| **Typed `SearchType` enum** | `auto·neural·keyword·fast·deep·instant` — invalid values rejected at parse time, not silently ignored |
| **Typed `LivecrawlOption` enum** | `never·fallback·preferred·always·auto` — same strict validation |
| **Errors to stderr + exit code 1** | Proper UNIX semantics — stdout is always clean JSON on success; errors never pollute the output stream |


path: install.sh
content: #!/usr/bin/env bash
# exa-search-rust installer
# Builds the exa-search Rust binary from bundled source and installs the skill.
set -e

SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$HOME/.openclaw/workspace/skills/exa-search-rust"

echo "🦀 Building exa-search binary..."
cd "$SKILL_DIR"
cargo build --release
mkdir -p "$SKILL_DIR/bin"
cp target/release/exa-search "$SKILL_DIR/bin/exa-search"
echo "✅ Binary built → $SKILL_DIR/bin/exa-search"

echo "📦 Installing skill to $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR/bin"
cp "$SKILL_DIR/bin/exa-search" "$INSTALL_DIR/bin/exa-search"
cp "$SKILL_DIR/SKILL.md" "$INSTALL_DIR/SKILL.md"
echo "✅ Skill installed"

echo ""
echo "🔑 API key setup:"
echo "   Add to ~/.openclaw/workspace/.env:"
echo "   EXA_API_KEY=your_key_here"
echo ""
echo "🧪 Test it:"
EXA_BIN="$INSTALL_DIR/bin/exa-search"
echo "   echo '{\"query\":\"hello world\"}' | EXA_API_KEY=your_key $EXA_BIN | jq ."
echo ""
echo "✅ Done."


path: src/client.rs
content: use crate::types::{
    ContentsResponse, FindSimilarOptions, FindSimilarResponse, GetContentsOptions, SearchOptions,
    SearchResponse,
};
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use reqwest::Client;

const EXA_BASE_URL: &str = "https://api.exa.ai";

pub struct ExaClient {
    client: Client,
    [REDACTED_SECRET],
}

impl ExaClient {
    pub fn new([REDACTED_SECRET]) -> Result<Self> {
        let client = Client::builder()
            .timeout(std::time::Duration::from_secs(60))
            .build()
            .context("Failed to build HTTP client")?;
        Ok(Self { client, api_key })
    }

    fn auth_headers(&self) -> Result<HeaderMap> {
        let mut headers = HeaderMap::new();
        let Ok(api_key_val) = HeaderValue::from_str(&self.api_key) else {
            anyhow::bail!("api_key contains invalid header characters");
        };
        headers.insert("x-api-key", api_key_val);
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
        Ok(headers)
    }

    pub async fn search(&self, opts: SearchOptions) -> Result<SearchResponse> {
        let url = format!("{EXA_BASE_URL}/search");
        let resp = self
            .client
            .post(&url)
            .headers(self.auth_headers()?)
            .json(&opts)
            .send()
            .await
            .context("HTTP request failed")?;
        self.parse_response(resp).await
    }

    pub async fn find_similar(&self, opts: FindSimilarOptions) -> Result<FindSimilarResponse> {
        let url = format!("{EXA_BASE_URL}/findSimilar");
        let resp = self
            .client
            .post(&url)
            .headers(self.auth_headers()?)
            .json(&opts)
            .send()
            .await
            .context("HTTP request failed")?;
        self.parse_response(resp).await
    }

    pub async fn get_contents(&self, opts: GetContentsOptions) -> Result<ContentsResponse> {
        let url = format!("{EXA_BASE_URL}/contents");
        let resp = self
            .client
            .post(&url)
            .headers(self.auth_headers()?)
            .json(&opts)
            .send()
            .await
            .context("HTTP request failed")?;
        self.parse_response(resp).await
    }

    /// Parse a response: propagate HTTP errors, then deserialize JSON.
    async fn parse_response<T: serde::de::DeserializeOwned>(
        &self,
        resp: reqwest::Response,
    ) -> Result<T> {
        const MAX_BODY: usize = 10 * 1024 * 1024; // 10MB
        let status = resp.status();
        let bytes = resp.bytes().await.context("Failed to read response body")?;
        if bytes.len() > MAX_BODY {
            anyhow::bail!("Response too large: {} bytes (max 10MB)", bytes.len());
        }
        let body = String::from_utf8(bytes.to_vec()).context("Response body is not valid UTF-8")?;
        if !status.is_success() {
            anyhow::bail!("Exa API error {status}: {body}");
        }
        serde_json::from_str(&body).with_context(|| format!("Failed to parse API response: {body}"))
    }
}


path: src/main.rs
content: #![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::struct_excessive_bools)]

mod client;
mod protocol;
mod types;

use client::ExaClient;
use protocol::{format_results, Input, Output};
use std::io::{self, Read};
use types::{
    params::{ContentsInput, ExtrasOptions},
    ContentsOptions, FindSimilarOptions, GetContentsOptions, SearchOptions, TextOptions,
};

#[tokio::main]
async fn main() {
    // ── Read API key ─────────────────────────────────────────────────────────
    let api_key = match std::env::var("EXA_API_KEY") {
        Ok(k) if !k.is_empty() => k,
        _ => emit_err("EXA_API_KEY environment variable not set"),
    };
    if !is_valid_uuid(&api_key) {
        emit_err(
            "EXA_API_KEY is invalid: expected UUID format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
        );
    }

    // ── Read stdin ───────────────────────────────────────────────────────────
    let mut limited = io::stdin().take(1_048_577); // 1MB + 1 byte
    let mut buf = String::new();
    if let Err(e) = limited.read_to_string(&mut buf) {
        emit_err(&format!("Failed to read stdin: {e}"));
    }
    if buf.len() > 1_048_576 {
        emit_err("Input too large: maximum is 1MB");
    }

    // ── Parse input ──────────────────────────────────────────────────────────
    let input: Input = match serde_json::from_str(&buf) {
        Ok(i) => i,
        Err(e) => emit_err(&format!("Invalid JSON input: {e}")),
    };

    // ── Build client ─────────────────────────────────────────────────────────
    let client = match ExaClient::new(api_key) {
        Ok(c) => c,
        Err(e) => emit_err(&format!("Failed to create HTTP client: {e}")),
    };

    // ── Dispatch ─────────────────────────────────────────────────────────────
    let action = input.action.as_deref().unwrap_or("search");

    match action {
        "search" => handle_search(client, input).await,
        "find_similar" => handle_find_similar(client, input).await,
        "get_contents" => handle_get_contents(client, input).await,
        other => emit_err(&format!("Unknown action: {other}")),
    }
}

// ── Search handler ────────────────────────────────────────────────────────────

async fn handle_search(client: ExaClient, input: Input) {
    let Some(query) = input.query else {
        emit_err("'query' is required for action 'search'");
    };

    // Resolve contents options
    let contents = Some(resolve_contents(
        input.contents.as_ref(),
        input.max_chars,
        input.filter_empty_results,
        input.extras,
        input.max_age_hours,
    ));

    let opts = SearchOptions {
        query,
        num_results: input.num_results,
        search_type: input.search_type,
        category: input.category,
        include_domains: input.include_domains,
        exclude_domains: input.exclude_domains,
        start_crawl_date: input.start_crawl_date,
        end_crawl_date: input.end_crawl_date,
        start_published_date: input.start_published_date,
        end_published_date: input.end_published_date,
        include_text: input.include_text,
        exclude_text: input.exclude_text,
        use_autoprompt: input.use_autoprompt,
        moderation: input.moderation,
        user_location: input.user_location,
        additional_queries: input.additional_queries,
        contents,
    };

    if opts.num_results.unwrap_or(0) > 50 {
        emit_err("num_results exceeds maximum of 50");
    }

    match client.search(opts).await {
        Ok(resp) => {
            let formatted = format_results(&resp.results);
            emit_ok(&Output::SearchOk {
                ok: true,
                action: "search".to_string(),
                results: resp.results,
                resolved_search_type: resp.resolved_search_type,
                auto_date: resp.auto_date,
                search_time_ms: resp.search_time_ms,
                cost_dollars: resp.cost_dollars,
                formatted,
            });
        }
        Err(e) => emit_err(&format!("Search failed: {e}")),
    }
}

// ── FindSimilar handler ───────────────────────────────────────────────────────

async fn handle_find_similar(client: ExaClient, input: Input) {
    let Some(url) = input.url else {
        emit_err("'url' is required for action 'find_similar'");
    };

    let contents = Some(resolve_contents(
        input.contents.as_ref(),
        input.max_chars,
        input.filter_empty_results,
        input.extras,
        input.max_age_hours,
    ));

    let opts = FindSimilarOptions {
        url,
        num_results: input.num_results,
        include_domains: input.include_domains,
        exclude_domains: input.exclude_domains,
        start_crawl_date: input.start_crawl_date,
        end_crawl_date: input.end_crawl_date,
        start_published_date: input.start_published_date,
        end_published_date: input.end_published_date,
        include_text: input.include_text,
        exclude_text: input.exclude_text,
        exclude_source_domain: input.exclude_source_domain,
        category: input.category,
        contents,
    };

    if opts.num_results.unwrap_or(0) > 50 {
        emit_err("num_results exceeds maximum of 50");
    }

    match client.find_similar(opts).await {
        Ok(resp) => {
            let formatted = format_results(&resp.results);
            emit_ok(&Output::SearchOk {
                ok: true,
                action: "find_similar".to_string(),
                results: resp.results,
                resolved_search_type: None,
                auto_date: None,
                search_time_ms: None,
                cost_dollars: resp.cost_dollars,
                formatted,
            });
        }
        Err(e) => emit_err(&format!("FindSimilar failed: {e}")),
    }
}

// ── GetContents handler ───────────────────────────────────────────────────────

async fn handle_get_contents(client: ExaClient, input: Input) {
    let urls = match input.urls {
        Some(u) if !u.is_empty() => u,
        _ => emit_err("'urls' (non-empty array) is required for action 'get_contents'"),
    };

    // For get_contents, contents options are passed as top-level fields
    let resolved = input
        .contents
        .map(ContentsInput::into_options)
        .unwrap_or_default();

    // Apply legacy max_chars to text if not already set
    let text = resolved.text.or_else(|| {
        input.max_chars.map(|mc| TextOptions {
            max_characters: Some(mc),
            ..Default::default()
        })
    });

    let opts = GetContentsOptions {
        urls,
        text,
        summary: resolved.summary,
        highlights: resolved.highlights,
        livecrawl: resolved.livecrawl,
        livecrawl_timeout: resolved.livecrawl_timeout,
        max_age_hours: resolved.max_age_hours.or(input.max_age_hours),
        filter_empty_results: resolved.filter_empty_results.or(input.filter_empty_results),
        subpages: resolved.subpages,
        subpage_target: resolved.subpage_target,
        extras: resolved.extras.or(input.extras),
    };

    match client.get_contents(opts).await {
        Ok(resp) => {
            emit_ok(&Output::ContentsOk {
                ok: true,
                action: "get_contents".to_string(),
                results: resp.results,
                cost_dollars: resp.cost_dollars,
            });
        }
        Err(e) => emit_err(&format!("GetContents failed: {e}")),
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Resolve contents from protocol input + legacy `max_chars` and top-level shorthands.
/// If neither is provided, defaults to text with `max_characters=10000`
/// (preserves backwards-compat with old behaviour).
fn resolve_contents(
    contents_input: Option<&ContentsInput>,
    max_chars: Option<u32>,
    filter_empty_results: Option<bool>,
    extras: Option<ExtrasOptions>,
    max_age_hours: Option<i32>,
) -> ContentsOptions {
    if let Some(ci) = contents_input {
        let mut opts = ci.clone().into_options();
        // Apply legacy max_chars if text is enabled with no max_characters set
        if let Some(mc) = max_chars {
            if let Some(ref mut text) = opts.text {
                if text.max_characters.is_none() {
                    text.max_characters = Some(mc);
                }
            }
        }
        // Apply top-level shorthands as fallbacks
        if opts.filter_empty_results.is_none() {
            opts.filter_empty_results = filter_empty_results;
        }
        if opts.extras.is_none() {
            opts.extras = extras;
        }
        if opts.max_age_hours.is_none() {
            opts.max_age_hours = max_age_hours;
        }
        opts
    } else {
        // Legacy default: always request text
        let max_characters = max_chars.or(Some(10_000));
        ContentsOptions {
            text: Some(TextOptions {
                max_characters,
                ..Default::default()
            }),
            filter_empty_results,
            extras,
            max_age_hours,
            ..Default::default()
        }
    }
}

/// Validates that a string matches UUID v4 format without a regex dependency.
/// Expected: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12 lowercase hex, dashes at fixed positions)
fn is_valid_uuid(s: &str) -> bool {
    let b = s.as_bytes();
    if b.len() != 36 {
        return false;
    }
    // Dashes must be at positions 8, 13, 18, 23
    if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
        return false;
    }
    // All other positions must be lowercase hex digits
    for (i, &ch) in b.iter().enumerate() {
        if i == 8 || i == 13 || i == 18 || i == 23 {
            continue;
        }
        if !ch.is_ascii_hexdigit() || ch.is_ascii_uppercase() {
            return false;
        }
    }
    true
}

fn emit_ok(output: &Output) {
    match serde_json::to_string(output) {
        Ok(s) => println!("{s}"),
        Err(e) => eprintln!("Fatal: failed to serialize output: {e}"),
    }
}

fn emit_err(msg: &str) -> ! {
    let output = Output::Err {
        ok: false,
        error: msg.to_string(),
    };
    match serde_json::to_string(&output) {
        Ok(s) => eprintln!("{s}"),
        Err(e) => eprintln!("{{\"ok\":false,\"error\":\"Fatal serialization error: {e}\"}}"),
    }
    std::process::exit(1)
}


path: src/protocol.rs
content: use crate::types::enums::SearchType;
use crate::types::params::{ContentsInput, ExtrasOptions};
use crate::types::result::ExaResult;
use serde::{Deserialize, Serialize};

/// Stdin JSON input. All fields optional except context-specific required ones.
/// `action` defaults to `"search"` when absent.
#[derive(Debug, Deserialize)]
pub struct Input {
    // ── action routing ─────────────────────────────────────────────────────
    pub action: Option<String>, // "search" | "find_similar" | "get_contents"

    // ── search + find_similar ──────────────────────────────────────────────
    pub query: Option<String>,
    pub num_results: Option<u32>,
    #[serde(rename = "type")]
    pub search_type: Option<SearchType>,
    pub category: Option<String>,
    pub include_domains: Option<Vec<String>>,
    pub exclude_domains: Option<Vec<String>>,
    pub start_crawl_date: Option<String>,
    pub end_crawl_date: Option<String>,
    pub start_published_date: Option<String>,
    pub end_published_date: Option<String>,
    pub include_text: Option<Vec<String>>,
    pub exclude_text: Option<Vec<String>>,
    pub use_autoprompt: Option<bool>,
    pub moderation: Option<bool>,
    pub user_location: Option<String>,
    pub additional_queries: Option<Vec<String>>,

    // ── find_similar only ──────────────────────────────────────────────────
    pub url: Option<String>,
    pub exclude_source_domain: Option<bool>,

    // ── get_contents only ─────────────────────────────────────────────────
    pub urls: Option<Vec<String>>,

    // ── shared contents options ────────────────────────────────────────────
    pub contents: Option<ContentsInput>,

    // ── legacy compat: max_chars → contents.text.max_characters ───────────
    pub max_chars: Option<u32>,

    // ── top-level contents shorthands (applied if contents object omits them) ──
    pub filter_empty_results: Option<bool>,
    pub extras: Option<ExtrasOptions>,
    pub max_age_hours: Option<i32>,
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum Output {
    SearchOk {
        ok: bool,
        action: String,
        results: Vec<ExaResult>,
        #[serde(skip_serializing_if = "Option::is_none")]
        resolved_search_type: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        auto_date: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        search_time_ms: Option<u64>,
        #[serde(skip_serializing_if = "Option::is_none")]
        cost_dollars: Option<serde_json::Value>,
        formatted: String,
    },
    ContentsOk {
        ok: bool,
        action: String,
        results: Vec<ExaResult>,
        #[serde(skip_serializing_if = "Option::is_none")]
        cost_dollars: Option<serde_json::Value>,
    },
    Err {
        ok: bool,
        error: String,
    },
}

/// Build the markdown-formatted string from results.
pub fn format_results(results: &[ExaResult]) -> String {
    results
        .iter()
        .map(|r| {
            let title = r.title.as_deref().unwrap_or("Untitled");
            let url = &r.url;
            let body = r.summary.as_deref().or(r.text.as_deref()).unwrap_or("");
            format!("## [{title}]({url})\n\n{body}\n\n---")
        })
        .collect::<Vec<_>>()
        .join("\n\n")
}


path: src/types/enums.rs
content: use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchType {
    Auto,
    Fast,
    Deep,
    Neural,
    Instant,
    Keyword,
}

/// Maps to Exa category strings (may contain spaces — use string passthrough).
/// Keep as a plain String in params; this enum is for documentation only.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Category {
    Company,
    #[serde(rename = "research paper")]
    ResearchPaper,
    News,
    Pdf,
    Tweet,
    #[serde(rename = "personal site")]
    PersonalSite,
    #[serde(rename = "financial report")]
    FinancialReport,
    People,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Livecrawl {
    Always,
    Fallback,
    Never,
    Auto,
    Preferred,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verbosity {
    Compact,
    Standard,
    Full,
}


path: src/types/mod.rs
content: pub mod enums;
pub mod params;
pub mod result;

pub use params::*;
pub use result::*;


path: src/types/params.rs
content: use super::enums::{Livecrawl, SearchType, Verbosity};
use serde::{Deserialize, Serialize};

// ─── Text contents option ─────────────────────────────────────────────────────

/// Sent to Exa when the caller requests text extraction.
/// `true` in input → serialize as `{}` (all defaults); object → full struct.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TextOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_characters: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_html_tags: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub verbosity: Option<Verbosity>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_sections: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_sections: Option<Vec<String>>,
}

// ─── Summary contents option ──────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SummaryOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub query: Option<String>,

    /// Arbitrary JSON Schema object — pass through as raw Value.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub schema: Option<serde_json::Value>,
}

// ─── Highlights contents option ───────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HighlightsOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub query: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_characters: Option<u32>,
}

// ─── Extras contents option ───────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExtrasOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub links: Option<u32>,
}

// ─── ContentsOptions (shared: search / find_similar / get_contents) ──────────

/// Mirrors the `contents` key in the stdin protocol.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContentsOptions {
    /// `true` → send `{}` (defaults); object → send full struct.
    /// Represented in protocol as `true | { ... }` — use `ContentsTextInput` below.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<TextOptions>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<SummaryOptions>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub highlights: Option<HighlightsOptions>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub livecrawl: Option<Livecrawl>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub livecrawl_timeout: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_age_hours: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub filter_empty_results: Option<bool>,

    /// Number of subpages to fetch.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subpages: Option<u32>,

    /// Target path(s) for subpages — string or array of strings.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subpage_target: Option<serde_json::Value>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub extras: Option<ExtrasOptions>,
}

// ─── Protocol-level Contents input ────────────────────────────────────────────

/// In stdin, `contents.text` can be `true` (enable with defaults) or an object.
/// Deserialize using an untagged enum then flatten to `TextOptions`.
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BoolOrTextOptions {
    Bool(bool),
    Options(TextOptions),
}

#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BoolOrSummaryOptions {
    Bool(bool),
    Options(SummaryOptions),
}

#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BoolOrHighlightsOptions {
    Bool(bool),
    Options(HighlightsOptions),
}

/// The `contents` field as it arrives from stdin.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ContentsInput {
    #[serde(default)]
    pub text: Option<BoolOrTextOptions>,

    #[serde(default)]
    pub summary: Option<BoolOrSummaryOptions>,

    #[serde(default)]
    pub highlights: Option<BoolOrHighlightsOptions>,

    #[serde(default)]
    pub livecrawl: Option<Livecrawl>,

    #[serde(default)]
    pub livecrawl_timeout: Option<u32>,

    #[serde(default)]
    pub max_age_hours: Option<i32>,

    #[serde(default)]
    pub filter_empty_results: Option<bool>,

    #[serde(default)]
    pub subpages: Option<u32>,

    #[serde(default)]
    pub subpage_target: Option<serde_json::Value>,

    #[serde(default)]
    pub extras: Option<ExtrasOptions>,
}

impl ContentsInput {
    /// Convert protocol input to the API-ready struct.
    pub fn into_options(self) -> ContentsOptions {
        ContentsOptions {
            text: self.text.map(|t| match t {
                BoolOrTextOptions::Bool(_) => TextOptions::default(),
                BoolOrTextOptions::Options(o) => o,
            }),
            summary: self.summary.map(|s| match s {
                BoolOrSummaryOptions::Bool(_) => SummaryOptions::default(),
                BoolOrSummaryOptions::Options(o) => o,
            }),
            highlights: self.highlights.map(|h| match h {
                BoolOrHighlightsOptions::Bool(_) => HighlightsOptions::default(),
                BoolOrHighlightsOptions::Options(o) => o,
            }),
            livecrawl: self.livecrawl,
            livecrawl_timeout: self.livecrawl_timeout,
            max_age_hours: self.max_age_hours,
            filter_empty_results: self.filter_empty_results,
            subpages: self.subpages,
            subpage_target: self.subpage_target,
            extras: self.extras,
        }
    }
}

// ─── SearchOptions (POST /search body) ───────────────────────────────────────

#[derive(Debug, Clone, Serialize)]
pub struct SearchOptions {
    pub query: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub num_results: Option<u32>,

    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub search_type: Option<SearchType>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_domains: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_domains: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_crawl_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_crawl_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_published_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_published_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_text: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_text: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub use_autoprompt: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub moderation: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_location: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub additional_queries: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub contents: Option<ContentsOptions>,
}

// ─── FindSimilarOptions (POST /findSimilar body) ─────────────────────────────

#[derive(Debug, Clone, Serialize)]
pub struct FindSimilarOptions {
    pub url: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub num_results: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_domains: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_domains: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_crawl_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_crawl_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_published_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_published_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_text: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_text: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_source_domain: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub contents: Option<ContentsOptions>,
}

// ─── GetContentsOptions (POST /contents body) ─────────────────────────────────

#[derive(Debug, Clone, Serialize)]
pub struct GetContentsOptions {
    pub urls: Vec<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<TextOptions>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<SummaryOptions>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub highlights: Option<HighlightsOptions>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub livecrawl: Option<Livecrawl>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub livecrawl_timeout: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_age_hours: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub filter_empty_results: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub subpages: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub subpage_target: Option<serde_json::Value>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub extras: Option<ExtrasOptions>,
}


path: src/types/result.rs
content: use serde::{Deserialize, Serialize};

// ─── Entity types ─────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::struct_field_names)]
pub struct Entity {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,

    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub entity_type: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<u32>,

    /// Arbitrary entity properties — pass through as raw JSON.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub properties: Option<serde_json::Value>,
}

// ─── Core result item ─────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExaResult {
    pub url: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub score: Option<f64>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub published_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub favicon: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub highlights: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub highlight_scores: Option<Vec<f64>>,

    /// Nested subpage results (recursive — same shape).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subpages: Option<Vec<ExaResult>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub extras: Option<serde_json::Value>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub entities: Option<Vec<Entity>>,
}

// ─── API response wrappers ────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
pub struct SearchResponse {
    pub results: Vec<ExaResult>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub resolved_search_type: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub auto_date: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub search_time_ms: Option<u64>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub cost_dollars: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
pub struct FindSimilarResponse {
    pub results: Vec<ExaResult>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub cost_dollars: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
pub struct ContentsResponse {
    pub results: Vec<ExaResult>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub cost_dollars: Option<serde_json::Value>,
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)