
Exa Search
- 67 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
exa-search is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- exa-search
- AI & Agent Building
- AI-coding skill
Exa Search by the numbers
- 67 all-time installs (skills.sh)
- Ranked #5,935 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill exa-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Searching with Exa (Search API)
This skill is a recipe for consistent web research using Exa’s Search API: you choose the right search mode, apply the right filters, request only the content you need (highlights/text/summary), and return clean citations.
Quick start (default path)
1) Ensure an API key is available as an environment variable:
EXA_API_KEY(preferred)- or pass
--api-keyto the scripts.
2) Run a search (JSON response to stdout):
python {baseDir}/scripts/exa_search.py --query "latest research in LLMs" --type auto --category "research paper" --num-results 5 --highlights --highlights-per-url 3 --num-sentences 2Operating principles (always follow)
- Always return URLs. Prefer also returning title + a 1–2 sentence “why this source” note.
- Prefer `highlights` first for agentic workflows. Escalate to full
textonly when necessary. - Use filters aggressively: domain allowlists, date windows, and
categoryimprove relevance and reduce noise. - Freshness is explicit: if the user asks for “latest”, “today”, “current”, etc., set a freshness policy (see below).
- Don’t overfetch: cap content length with
maxCharacterswhen requestingtext. - If the user needs hard evidence (numbers, quotes), fetch full text for the top 1–3 pages and verify.
Workflow
Step 1 — Translate the user task into a search plan
Decide:
1. Search type
auto(default): best general quality.instant: lowest latency, for autocomplete / live suggestions.deep: more comprehensive; can useadditionalQueries.fast/neural: streamlined alternatives.
2. Category (when appropriate)
news,research paper,company,people,tweet,personal site,financial report, etc.
3. Freshness
- If “real-time / latest”: consider live crawling via
maxAgeHours(see Freshness). - If “historical/static”: use cache only (e.g.,
maxAgeHours: -1).
4. Content mode
highlights: token-efficient evidence snippets.text: deep reading (cap viamaxCharacters).summary: quick structured overviews (optionally with a guidingquery).
Step 2 — Build the request payload
Start from this template and fill only what you need:
{
"query": "...",
"type": "auto",
"category": "news",
"numResults": 10,
"includeDomains": ["..."],
"excludeDomains": ["..."],
"startPublishedDate": "2025-01-01T00:00:00.000Z",
"endPublishedDate": "2025-12-31T23:59:59.999Z",
"includeText": ["must contain phrase"],
"excludeText": ["must not contain phrase"],
"contents": {
"highlights": true,
"text": { "maxCharacters": 8000, "includeHtmlTags": false },
"summary": { "query": "..." },
"subpages": 0,
"extras": { "links": 0, "imageLinks": 0 },
"maxAgeHours": 24
}
}Notes:
contentsis optional. If omitted, you’ll only get metadata (title,url, etc.).maxAgeHourscontrols when Exa should live-crawl vs use cached content (see below).contextis deprecated; usehighlightsortextinstead.
Step 3 — Execute the request
Option A (recommended): use the bundled script so requests are consistent and validated.
python {baseDir}/scripts/exa_search.py --query "..." --highlights --num-results 10Option B: call the HTTP endpoint directly.
curl --request POST --url https://api.exa.ai/search --header "content-type: application/json" --header "x-api-key: $EXA_API_KEY" --data '{"query":"...","type":"auto","numResults":5}'Step 4 — Post-process results into an answer with citations
1. De-duplicate near-identical domains/pages when the user wants breadth. 2. Select the top sources (usually 3–7) that jointly cover the claim space. 3. For each selected result, extract:
- title, url
- key highlight(s) or a short quote from
text - published date (if available)
4. Write the response with inline citations (URLs) and clear uncertainty where needed. 5. If the user wants a deliverable (report, memo), preserve a “Sources” section listing all URLs.
Freshness policy (use this when “latest/current/today” appears)
Use contents.maxAgeHours (or the maxAgeHours top-level alias if the API accepts it):
24: daily-fresh content (use cache if <24h else livecrawl)1: near-real-time (cache if <1h else livecrawl)0: always livecrawl (slowest, most current)-1: never livecrawl (fastest; cache only)- omit: default behaviour (livecrawl only when cache missing)
Common patterns
Pattern A — “Give me sources for X” (fast + token efficient)
type: auto,numResults: 5–10contents.highlights: true- Optional:
categoryandincludeDomains
Pattern B — “Do deep research on X” (read a few pages thoroughly)
- Start with highlights on 10–20 results.
- Then fetch full
textfor the top 3–5 URLs with amaxCharacterscap. - Summarise with citations.
Pattern C — “Latest news about X”
category: news- Apply a date window (
startPublishedDate) if the question is time-bound. - Use a freshness setting (often
maxAgeHours: 1–24).
Pattern D — “Find a company / person page”
category: companyorpeople- If using
people, allowlist LinkedIn domains when needed. - IMPORTANT: some filters are unsupported for
company/people; see troubleshooting.
Troubleshooting
401 / 403 (auth)
- Confirm
x-api-keyheader is present and valid. - Confirm you aren’t accidentally using a placeholder like
YOUR-EXA-API-KEY.
400 (invalid parameters)
companyandpeoplecategories support a limited set of filters; unsupported parameters can trigger 400 errors.- If in doubt, remove date and text filters first, then re-add one-by-one.
Too much content / token blow-ups
- Prefer
highlightsovertext. - Cap
text.maxCharacters. - Reduce
numResults.
Bundled references
- API + parameter cheat sheet:
references/exa-search-api.md - Best-practice recipes:
references/exa-search-best-practices.md - Quickstart snippets (SDK + curl):
references/exa-search-quickstart.md
Exa Search API — cheat sheet
This is a condensed reference for the Exa Search endpoint and the most-used request fields.
Endpoint
POST https://api.exa.ai/search
Auth:
x-api-key: <YOUR_KEY>header (orAuthorization: Bearer <YOUR_KEY>)
Core request fields
Required:
query(string): natural-language query; can be long and semantically rich.
Common:
type(string):auto(default),instant,deep,fast,neuralcategory(string): e.g.news,research paper,company,people,tweet,personal site,financial report, etc.numResults(int): 1–100 (default 10)
Deep search expansion:
additionalQueries(string[]): only fortype: "deep".
Filters:
includeDomains/excludeDomains(string[])startCrawlDate/endCrawlDate(ISO 8601 datetime)startPublishedDate/endPublishedDate(ISO 8601 datetime)includeText/excludeText(string[]): currently supports 1 phrase (up to ~5 words);excludeTextchecks early page text.userLocation(string): 2-letter ISO country code (e.g.,US)
Safety:
moderation(boolean): enable content moderation (if available on your plan).
Category gotchas (important)
company and people categories support a limited set of filters. Using unsupported filters may return a 400 error. In particular, some date/text filters and excludeDomains may be unsupported for these categories.
Returning contents
You can request contents from results directly via contents:
{
"query": "…",
"contents": {
"highlights": true,
"text": { "maxCharacters": 8000, "includeHtmlTags": false },
"summary": { "query": "…optional steering prompt…" },
"subpages": 0,
"subpageTarget": "sources",
"extras": { "links": 0, "imageLinks": 0 },
"maxAgeHours": 24
}
}Text
text: truereturns full text with defaults.text: { maxCharacters, includeHtmlTags }limits size and optionally preserves HTML tags.
Highlights
Highlights are short excerpts selected to match your query. Depending on API version, the object form may support:
highlights: true(defaults)highlights: { numSentences, highlightsPerUrl, query }(more control)
Summary
Summaries can be steered with:
summary: { query: "What you want summarised/extracted" }
Freshness / live crawling
Use maxAgeHours to control cache vs live-crawl behaviour:
24daily-fresh,1near-real-time,0always live-crawl,-1cache-only, omit for default fallback behaviour.
Typical response fields
requestIdresults[]includingtitle,url,publishedDate,author, plus any requestedtext/highlights/summarysearchType(forauto)costDollars(if enabled)
When to split into 2 calls
For large or expensive workflows: 1) POST /search without contents to get URLs quickly. 2) POST /contents on the top N URLs to fetch only what you need.
Exa Search — best-practice recipes
Pick the right search type
auto: best general quality.instant: low latency, great for autocomplete and quick suggestions.deep: comprehensive research; can use query expansion.fast/neural: lightweight alternatives.
Token efficiency: prefer highlights
For multi-step agent workflows, request highlights first and only fetch full text when necessary.
A common pattern: 1) contents.highlights on 10 results. 2) If needed, contents.text on the best 3 results (cap with maxCharacters).
Control verbosity when using full text
When requesting text, use:
maxCharactersto cap size- (if supported)
verbosityto include/exclude boilerplate like navigation, footers, etc.
Freshness: be explicit when “latest” matters
Use maxAgeHours to control cache vs live crawling:
24: cache if <24h old, otherwise live-crawl1: cache if <1h old, otherwise live-crawl0: always live-crawl-1: cache only- omit: default fallback behaviour
Category filters
Use category to target sources:
news,research paper,personal site,financial report,tweet, plus entity-focused categories likecompanyandpeople.
When using company/people, keep filters minimal to avoid unsupported-parameter errors.
Exa Search — quickstart snippets
Python (SDK)
Install:
pip install exa-pyUse:
from exa_py import Exa
exa = Exa(api_key="YOUR_EXA_API_KEY")
res = exa.search_and_contents(
"blog post about artificial intelligence",
type="auto",
num_results=5,
text=True,
)
print(res)JavaScript (SDK)
Install:
npm install exa-jsUse:
import Exa from 'exa-js';
const exa = new Exa(process.env.EXA_API_KEY);
const res = await exa.searchAndContents('blog post about artificial intelligence', {
type: 'auto',
numResults: 5,
contents: { text: true },
});
console.log(res);cURL
curl --request POST \
--url https://api.exa.ai/search \
--header "accept: application/json" \
--header "content-type: application/json" \
--header "x-api-key: $EXA_API_KEY" \
--data '{
"query": "blog post about artificial intelligence",
"type": "auto",
"contents": { "text": true }
}'#!/usr/bin/env python3
"""
exa_contents.py — small CLI for Exa Contents API (POST /contents)
- No third-party deps (uses urllib).
- Reads API key from EXA_API_KEY by default.
- Prints JSON to stdout.
Examples:
python scripts/exa_contents.py --urls https://example.com,https://example.org --text --text-max-chars 3000
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
EXA_API_URL = "https://api.exa.ai/contents"
def _split_csv(value: Optional[str]) -> Optional[List[str]]:
if value is None:
return None
items = [v.strip() for v in value.split(",") if v.strip()]
return items or None
def exa_contents(api_key: str, payload: Dict[str, Any], timeout_s: int = 60) -> Dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
EXA_API_URL,
method="POST",
data=data,
headers={
"content-type": "application/json",
"accept": "application/json",
"x-api-key": api_key,
},
)
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {e.code} from Exa: {body}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"Network error calling Exa: {e}") from e
def main() -> int:
p = argparse.ArgumentParser(description="Call Exa Contents API and print JSON.")
p.add_argument("--urls", help="Comma-separated URLs to fetch contents for")
p.add_argument("--ids", help="Comma-separated document IDs from Exa search results (legacy)")
p.add_argument("--text", action="store_true", help="Request full text")
p.add_argument("--text-max-chars", type=int, help="Cap returned text size")
p.add_argument("--include-html-tags", action="store_true", help="Include HTML tags in returned text")
p.add_argument("--highlights", action="store_true", help="Request highlights")
p.add_argument("--highlights-query", help="Steer highlights with a custom query")
p.add_argument("--highlights-per-url", type=int, help="Snippets per URL")
p.add_argument("--num-sentences", type=int, help="Sentences per snippet")
p.add_argument("--summary", action="store_true", help="Request summaries")
p.add_argument("--summary-query", help="Steer summaries with a custom query")
p.add_argument("--subpages", type=int, help="Number of subpages to crawl")
p.add_argument("--subpage-target", help="Keyword(s) to target subpages; comma-separated for multiple")
p.add_argument("--extras-links", type=int, help="Number of links to return per page")
p.add_argument("--extras-image-links", type=int, help="Number of image links to return per page")
p.add_argument("--max-age-hours", type=int, help="Freshness policy for cached content")
p.add_argument("--api-key", help="Exa API key (otherwise uses EXA_API_KEY env var)")
p.add_argument("--timeout", type=int, default=60, help="HTTP timeout seconds (default: 60)")
p.add_argument("--compact", action="store_true", help="Compact JSON output (no pretty-print)")
args = p.parse_args()
api_key = args.api_key or os.environ.get("EXA_API_KEY")
if not api_key:
print("Missing API key. Set EXA_API_KEY or pass --api-key.", file=sys.stderr)
return 2
urls = _split_csv(args.urls)
ids = _split_csv(args.ids)
if not urls and not ids:
print("Provide --urls or --ids.", file=sys.stderr)
return 2
payload: Dict[str, Any] = {}
if urls:
payload["urls"] = urls
if ids:
payload["ids"] = ids
if args.text:
if args.text_max_chars is None and not args.include_html_tags:
payload["text"] = True
else:
payload["text"] = {
"maxCharacters": args.text_max_chars if args.text_max_chars is not None else 10000,
"includeHtmlTags": bool(args.include_html_tags),
}
if args.highlights:
hl: Dict[str, Any] = {}
if args.highlights_query:
hl["query"] = args.highlights_query
if args.num_sentences is not None:
hl["numSentences"] = args.num_sentences
if args.highlights_per_url is not None:
hl["highlightsPerUrl"] = args.highlights_per_url
payload["highlights"] = hl if hl else True
if args.summary:
payload["summary"] = {"query": args.summary_query} if args.summary_query else True
if args.subpages is not None:
payload["subpages"] = args.subpages
if args.subpage_target is not None:
target = _split_csv(args.subpage_target)
payload["subpageTarget"] = target if (target and len(target) > 1) else (target[0] if target else args.subpage_target)
extras: Dict[str, Any] = {}
if args.extras_links is not None:
extras["links"] = args.extras_links
if args.extras_image_links is not None:
extras["imageLinks"] = args.extras_image_links
if extras:
payload["extras"] = extras
if args.max_age_hours is not None:
payload["maxAgeHours"] = args.max_age_hours
try:
res = exa_contents(api_key=api_key, payload=payload, timeout_s=args.timeout)
except Exception as e:
print(str(e), file=sys.stderr)
return 1
if args.compact:
print(json.dumps(res, ensure_ascii=False))
else:
print(json.dumps(res, ensure_ascii=False, indent=2, sort_keys=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
exa_search.py — small CLI for Exa Search API (POST /search)
- No third-party deps (uses urllib).
- Reads API key from EXA_API_KEY by default.
- Prints JSON to stdout.
Examples:
python scripts/exa_search.py --query "latest research in LLMs" --highlights --num-results 5
python scripts/exa_search.py --query "latest announcements from OpenAI" --include-domains openai.com --text --max-age-hours 24
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
EXA_API_URL = "https://api.exa.ai/search"
def _split_csv(value: Optional[str]) -> Optional[List[str]]:
if value is None:
return None
items = [v.strip() for v in value.split(",") if v.strip()]
return items or None
def _maybe_int(value: Optional[str]) -> Optional[int]:
if value is None:
return None
return int(value)
def _build_contents(args: argparse.Namespace) -> Optional[Dict[str, Any]]:
want_any = (
args.highlights
or args.text
or args.summary
or args.subpages is not None
or args.subpage_target is not None
or args.extras_links is not None
or args.extras_image_links is not None
or args.max_age_hours is not None
)
if not want_any:
return None
contents: Dict[str, Any] = {}
if args.highlights:
# Some API versions support object options. Keep it flexible.
hl_obj: Dict[str, Any] = {}
if args.highlights_query:
hl_obj["query"] = args.highlights_query
if args.num_sentences is not None:
hl_obj["numSentences"] = args.num_sentences
if args.highlights_per_url is not None:
hl_obj["highlightsPerUrl"] = args.highlights_per_url
if args.highlights_max_chars is not None:
hl_obj["maxCharacters"] = args.highlights_max_chars
contents["highlights"] = hl_obj if hl_obj else True
if args.text:
if args.text_max_chars is None and not args.include_html_tags and args.text_verbosity is None:
contents["text"] = True
else:
txt_obj: Dict[str, Any] = {}
if args.text_max_chars is not None:
txt_obj["maxCharacters"] = args.text_max_chars
if args.include_html_tags:
txt_obj["includeHtmlTags"] = True
if args.text_verbosity is not None:
txt_obj["verbosity"] = args.text_verbosity
contents["text"] = txt_obj
if args.summary:
if args.summary_query is None:
contents["summary"] = True
else:
contents["summary"] = {"query": args.summary_query}
if args.subpages is not None:
contents["subpages"] = args.subpages
if args.subpage_target is not None:
target = _split_csv(args.subpage_target)
contents["subpageTarget"] = target if (target and len(target) > 1) else (target[0] if target else args.subpage_target)
extras: Dict[str, Any] = {}
if args.extras_links is not None:
extras["links"] = args.extras_links
if args.extras_image_links is not None:
extras["imageLinks"] = args.extras_image_links
if extras:
contents["extras"] = extras
if args.max_age_hours is not None:
contents["maxAgeHours"] = args.max_age_hours
return contents
def _validate(args: argparse.Namespace, payload: Dict[str, Any]) -> None:
if args.type != "deep" and args.additional_queries:
raise SystemExit("--additional-queries only works with --type deep")
category = payload.get("category")
if category in {"company", "people"}:
# From Exa docs: these categories support a limited subset of filters.
forbidden = []
for k in (
"startPublishedDate",
"endPublishedDate",
"startCrawlDate",
"endCrawlDate",
"includeText",
"excludeText",
"excludeDomains",
):
if k in payload:
forbidden.append(k)
if forbidden:
raise SystemExit(
f"Category '{category}' does not support these filters: {', '.join(forbidden)}. "
f"Remove them or change category."
)
if category == "people" and "includeDomains" in payload:
# People category may limit includeDomains to LinkedIn.
# We don't enforce the exact domain here, but we warn.
domains = payload.get("includeDomains") or []
if any("linkedin.com" not in d for d in domains):
print(
"Warning: category 'people' may only support LinkedIn domains for includeDomains. "
"If you get a 400, restrict includeDomains to linkedin.com.",
file=sys.stderr,
)
def exa_search(api_key: str, payload: Dict[str, Any], timeout_s: int = 60) -> Dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
EXA_API_URL,
method="POST",
data=data,
headers={
"content-type": "application/json",
"accept": "application/json",
"x-api-key": api_key,
},
)
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {e.code} from Exa: {body}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"Network error calling Exa: {e}") from e
def main() -> int:
p = argparse.ArgumentParser(description="Call Exa Search API and print JSON.")
p.add_argument("--query", required=True, help="Search query string")
p.add_argument("--type", default="auto", choices=["auto", "instant", "deep", "fast", "neural"], help="Search type")
p.add_argument("--category", help="Category filter, e.g. news, research paper, company, people")
p.add_argument("--num-results", type=int, default=10, dest="num_results", help="Number of results (1-100)")
p.add_argument("--include-domains", help="Comma-separated domains to include, e.g. arxiv.org,paperswithcode.com")
p.add_argument("--exclude-domains", help="Comma-separated domains to exclude")
p.add_argument("--start-crawl-date", help="ISO 8601 datetime (e.g. 2023-01-01T00:00:00.000Z)")
p.add_argument("--end-crawl-date", help="ISO 8601 datetime")
p.add_argument("--start-published-date", help="ISO 8601 datetime")
p.add_argument("--end-published-date", help="ISO 8601 datetime")
p.add_argument("--include-text", help="Phrase that must appear in page text (use sparingly)")
p.add_argument("--exclude-text", help="Phrase that must NOT appear in page text (use sparingly)")
p.add_argument("--user-location", help="Two-letter ISO country code (e.g. US)")
p.add_argument("--additional-queries", help="Comma-separated query variants (deep search only)")
p.add_argument("--moderation", action="store_true", help="Enable content moderation (if available)")
# Contents controls
p.add_argument("--highlights", action="store_true", help="Request highlights in contents")
p.add_argument("--highlights-query", help="Steer highlight selection with a custom query")
p.add_argument("--highlights-per-url", type=int, help="Snippets per result (if supported)")
p.add_argument("--num-sentences", type=int, help="Sentences per snippet (if supported)")
p.add_argument("--highlights-max-chars", type=int, help="Cap highlight size (if supported)")
p.add_argument("--text", action="store_true", help="Request full text in contents")
p.add_argument("--text-max-chars", type=int, help="Cap returned text size")
p.add_argument("--include-html-tags", action="store_true", help="Include HTML tags in text (if supported)")
p.add_argument("--text-verbosity", choices=["compact", "standard", "full"], help="Verbosity level (if supported)")
p.add_argument("--summary", action="store_true", help="Request summaries in contents")
p.add_argument("--summary-query", help="Steer summaries with a custom query")
p.add_argument("--subpages", type=int, help="Number of subpages to crawl")
p.add_argument("--subpage-target", help="Keyword(s) to target subpages; comma-separated for multiple")
p.add_argument("--extras-links", type=int, help="Number of links to return per result")
p.add_argument("--extras-image-links", type=int, help="Number of image links to return per result")
p.add_argument("--max-age-hours", type=int, help="Freshness policy for cached content")
p.add_argument("--api-key", help="Exa API key (otherwise uses EXA_API_KEY env var)")
p.add_argument("--timeout", type=int, default=60, help="HTTP timeout seconds (default: 60)")
p.add_argument("--compact", action="store_true", help="Compact JSON output (no pretty-print)")
args = p.parse_args()
api_key = args.api_key or os.environ.get("EXA_API_KEY")
if not api_key:
print("Missing API key. Set EXA_API_KEY or pass --api-key.", file=sys.stderr)
return 2
payload: Dict[str, Any] = {"query": args.query, "type": args.type, "numResults": args.num_results}
if args.category:
payload["category"] = args.category
if args.include_domains:
payload["includeDomains"] = _split_csv(args.include_domains)
if args.exclude_domains:
payload["excludeDomains"] = _split_csv(args.exclude_domains)
if args.start_crawl_date:
payload["startCrawlDate"] = args.start_crawl_date
if args.end_crawl_date:
payload["endCrawlDate"] = args.end_crawl_date
if args.start_published_date:
payload["startPublishedDate"] = args.start_published_date
if args.end_published_date:
payload["endPublishedDate"] = args.end_published_date
if args.include_text:
payload["includeText"] = [args.include_text]
if args.exclude_text:
payload["excludeText"] = [args.exclude_text]
if args.user_location:
payload["userLocation"] = args.user_location
if args.additional_queries:
payload["additionalQueries"] = _split_csv(args.additional_queries)
if args.moderation:
payload["moderation"] = True
contents = _build_contents(args)
if contents is not None:
payload["contents"] = contents
_validate(args, payload)
try:
res = exa_search(api_key=api_key, payload=payload, timeout_s=args.timeout)
except Exception as e:
print(str(e), file=sys.stderr)
return 1
if args.compact:
print(json.dumps(res, ensure_ascii=False))
else:
print(json.dumps(res, ensure_ascii=False, indent=2, sort_keys=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
validate_exa_request.py — lightweight validator for Exa /search request JSON.
Usage:
python scripts/validate_exa_request.py request.json
cat request.json | python scripts/validate_exa_request.py -
Exit codes:
0 OK
2 Invalid request
"""
from __future__ import annotations
import json
import sys
from typing import Any, Dict
FORBIDDEN_FOR_COMPANY_PEOPLE = {
"startPublishedDate",
"endPublishedDate",
"startCrawlDate",
"endCrawlDate",
"includeText",
"excludeText",
"excludeDomains",
}
def die(msg: str) -> int:
print(f"Invalid: {msg}", file=sys.stderr)
return 2
def main() -> int:
if len(sys.argv) != 2:
print("Usage: validate_exa_request.py <request.json|->", file=sys.stderr)
return 2
path = sys.argv[1]
raw = sys.stdin.read() if path == "-" else open(path, "r", encoding="utf-8").read()
try:
payload: Dict[str, Any] = json.loads(raw)
except Exception as e:
return die(f"Could not parse JSON: {e}")
if not isinstance(payload.get("query"), str) or not payload["query"].strip():
return die("Missing non-empty 'query' (string)")
t = payload.get("type", "auto")
if t != "deep" and payload.get("additionalQueries"):
return die("'additionalQueries' only works with type='deep'")
n = payload.get("numResults", 10)
if not isinstance(n, int) or n < 1 or n > 100:
return die("'numResults' must be an integer in [1, 100]")
category = payload.get("category")
if category in {"company", "people"}:
bad = [k for k in FORBIDDEN_FOR_COMPANY_PEOPLE if k in payload]
if bad:
return die(f"category '{category}' does not support: {', '.join(sorted(bad))}")
# contents sanity checks (optional)
contents = payload.get("contents")
if contents is not None and not isinstance(contents, dict):
return die("'contents' must be an object if provided")
print("OK", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())