@@ -17,7 +17,7 @@ import {
1717 writeCache ,
1818} from "./web-shared.js" ;
1919
20- const SEARCH_PROVIDERS = [ "brave" , "perplexity" , "grok" ] as const ;
20+ const SEARCH_PROVIDERS = [ "brave" , "perplexity" , "grok" , "exa" ] as const ;
2121const DEFAULT_SEARCH_COUNT = 5 ;
2222const MAX_SEARCH_COUNT = 10 ;
2323
@@ -36,6 +36,9 @@ const ANTHROPIC_MESSAGES_ENDPOINT =
3636 "/v1/messages" ;
3737const DEFAULT_ANTHROPIC_SEARCH_MODEL = "claude-sonnet-4-5-20250929" ;
3838
39+ const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search" ;
40+ const DEFAULT_EXA_MAX_CHARS = 1500 ;
41+
3942const SEARCH_CACHE = new Map < string , CacheEntry < Record < string , unknown > > > ( ) ;
4043const BRAVE_FRESHNESS_SHORTCUTS = new Set ( [ "pd" , "pw" , "pm" , "py" ] ) ;
4144const BRAVE_FRESHNESS_RANGE = / ^ ( \d { 4 } - \d { 2 } - \d { 2 } ) t o ( \d { 4 } - \d { 2 } - \d { 2 } ) $ / ;
@@ -106,6 +109,13 @@ type GrokConfig = {
106109 inlineCitations ?: boolean ;
107110} ;
108111
112+ type ExaConfig = {
113+ apiKey ?: string ;
114+ contents ?: boolean ;
115+ maxChars ?: number ;
116+ } ;
117+
118+
109119type GrokSearchResponse = {
110120 output_text ?: string ;
111121 citations ?: string [ ] ;
@@ -179,6 +189,14 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
179189}
180190
181191function missingSearchKeyPayload ( provider : ( typeof SEARCH_PROVIDERS ) [ number ] ) {
192+ if ( provider === "exa" ) {
193+ return {
194+ error : "missing_exa_api_key" ,
195+ message :
196+ "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey." ,
197+ docs : "https://docs.openclaw.ai/tools/web" ,
198+ } ;
199+ }
182200 if ( provider === "perplexity" ) {
183201 return {
184202 error : "missing_perplexity_api_key" ,
@@ -338,6 +356,41 @@ function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
338356 return grok ?. inlineCitations === true ;
339357}
340358
359+ function resolveExaConfig ( search ?: WebSearchConfig ) : ExaConfig {
360+ if ( ! search || typeof search !== "object" ) {
361+ return { } ;
362+ }
363+ const exa = "exa" in search ? search . exa : undefined ;
364+ if ( ! exa || typeof exa !== "object" ) {
365+ return { } ;
366+ }
367+ return exa as ExaConfig ;
368+ }
369+
370+ function resolveExaApiKey ( exa ?: ExaConfig ) : string | undefined {
371+ const fromConfig = normalizeApiKey ( exa ?. apiKey ) ;
372+ if ( fromConfig ) {
373+ return fromConfig ;
374+ }
375+ const fromEnv = normalizeApiKey ( process . env . EXA_API_KEY ) ;
376+ return fromEnv || undefined ;
377+ }
378+
379+ function resolveExaContents ( exa ?: ExaConfig ) : boolean {
380+ if ( exa && typeof exa . contents === "boolean" ) {
381+ return exa . contents ;
382+ }
383+ return true ;
384+ }
385+
386+ function resolveExaMaxChars ( exa ?: ExaConfig ) : number {
387+ if ( exa && typeof exa . maxChars === "number" && exa . maxChars > 0 ) {
388+ return exa . maxChars ;
389+ }
390+ return DEFAULT_EXA_MAX_CHARS ;
391+ }
392+
393+
341394function resolveAnthropicApiKey ( ) : string | undefined {
342395 const fromEnv = normalizeApiKey ( process . env . ANTHROPIC_API_KEY ) ;
343396 return fromEnv || undefined ;
@@ -567,6 +620,69 @@ async function runGrokSearch(params: {
567620 return { content, citations, inlineCitations } ;
568621}
569622
623+
624+ async function runExaSearch ( params : {
625+ query : string ;
626+ count : number ;
627+ apiKey : string ;
628+ timeoutSeconds : number ;
629+ contents : boolean ;
630+ maxChars : number ;
631+ } ) : Promise < {
632+ results : Array < {
633+ title : string ;
634+ url : string ;
635+ description : string ;
636+ published ?: string ;
637+ } > ;
638+ } > {
639+ const body : Record < string , unknown > = {
640+ query : params . query ,
641+ numResults : params . count ,
642+ type : "auto" ,
643+ } ;
644+ if ( params . contents ) {
645+ body . contents = {
646+ text : { maxCharacters : params . maxChars } ,
647+ } ;
648+ }
649+
650+ const res = await fetch ( EXA_SEARCH_ENDPOINT , {
651+ method : "POST" ,
652+ headers : {
653+ "Content-Type" : "application/json" ,
654+ "x-api-key" : params . apiKey ,
655+ "x-exa-integration" : "openclaw" ,
656+ } ,
657+ body : JSON . stringify ( body ) ,
658+ signal : withTimeout ( undefined , params . timeoutSeconds * 1000 ) ,
659+ } ) ;
660+
661+ if ( ! res . ok ) {
662+ const detailResult = await readResponseText ( res , { maxBytes : 64_000 } ) ;
663+ const detail = detailResult . text ;
664+ throw new Error ( `Exa API error (${ res . status } ): ${ detail || res . statusText } ` ) ;
665+ }
666+
667+ const data = ( await res . json ( ) ) as {
668+ results ?: Array < {
669+ title ?: string ;
670+ url ?: string ;
671+ text ?: string ;
672+ publishedDate ?: string ;
673+ } > ;
674+ } ;
675+
676+ return {
677+ results : ( data . results ?? [ ] ) . map ( ( r ) => ( {
678+ title : r . title ?? "" ,
679+ url : r . url ?? "" ,
680+ description : r . text ?? "" ,
681+ published : r . publishedDate ?? undefined ,
682+ } ) ) ,
683+ } ;
684+ }
685+
570686async function runWebSearch ( params : {
571687 query : string ;
572688 count : number ;
@@ -582,9 +698,13 @@ async function runWebSearch(params: {
582698 perplexityModel ?: string ;
583699 grokModel ?: string ;
584700 grokInlineCitations ?: boolean ;
701+ exaContents ?: boolean ;
702+ exaMaxChars ?: number ;
585703} ) : Promise < Record < string , unknown > > {
586704 const cacheKey = normalizeCacheKey (
587- params . provider === "brave"
705+ params . provider === "exa"
706+ ? `${ params . provider } :${ params . query } :${ params . count } :${ String ( params . exaContents ?? true ) } :${ params . exaMaxChars ?? DEFAULT_EXA_MAX_CHARS } `
707+ : params . provider === "brave"
588708 ? `${ params . provider } :${ params . query } :${ params . count } :${ params . country || "default" } :${ params . search_lang || "default" } :${ params . ui_lang || "default" } :${ params . freshness || "default" } `
589709 : params . provider === "perplexity"
590710 ? `${ params . provider } :${ params . query } :${ params . perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL } :${ params . perplexityModel ?? DEFAULT_PERPLEXITY_MODEL } `
@@ -639,6 +759,41 @@ async function runWebSearch(params: {
639759 writeCache ( SEARCH_CACHE , cacheKey , payload , params . cacheTtlMs ) ;
640760 return payload ;
641761 }
762+ if ( params . provider === "exa" ) {
763+ const exaResult = await runExaSearch ( {
764+ query : params . query ,
765+ count : params . count ,
766+ apiKey : params . apiKey ,
767+ timeoutSeconds : params . timeoutSeconds ,
768+ contents : params . exaContents ?? true ,
769+ maxChars : params . exaMaxChars ?? DEFAULT_EXA_MAX_CHARS ,
770+ } ) ;
771+
772+ const mapped = exaResult . results . map ( ( entry ) => ( {
773+ title : entry . title ? wrapWebContent ( entry . title , "web_search" ) : "" ,
774+ url : entry . url ,
775+ description : entry . description ? wrapWebContent ( entry . description , "web_search" ) : "" ,
776+ published : entry . published || undefined ,
777+ siteName : resolveSiteName ( entry . url ) || undefined ,
778+ } ) ) ;
779+
780+ const payload = {
781+ query : params . query ,
782+ provider : params . provider ,
783+ count : mapped . length ,
784+ tookMs : Date . now ( ) - start ,
785+ externalContent : {
786+ untrusted : true ,
787+ source : "web_search" ,
788+ provider : params . provider ,
789+ wrapped : true ,
790+ } ,
791+ results : mapped ,
792+ } ;
793+ writeCache ( SEARCH_CACHE , cacheKey , payload , params . cacheTtlMs ) ;
794+ return payload ;
795+ }
796+
642797
643798 if ( params . provider !== "brave" ) {
644799 throw new Error ( "Unsupported web search provider." ) ;
@@ -711,10 +866,14 @@ export function createWebSearchTool(options?: {
711866 }
712867
713868 const provider = resolveSearchProvider ( search ) ;
869+ const exaConfig = resolveExaConfig ( search ) ;
714870 const perplexityConfig = resolvePerplexityConfig ( search ) ;
715871 const grokConfig = resolveGrokConfig ( search ) ;
716872
717873 const description =
874+ provider === "exa"
875+ ? "Search the web using Exa. Returns structured results with optional page text."
876+ : const description =
718877 provider === "perplexity"
719878 ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
720879 : provider === "grok"
@@ -734,7 +893,9 @@ export function createWebSearchTool(options?: {
734893 ? perplexityAuth ?. apiKey
735894 : provider === "grok"
736895 ? resolveGrokApiKey ( grokConfig )
737- : resolveSearchApiKey ( search ) ;
896+ : provider === "exa"
897+ ? resolveExaApiKey ( exaConfig )
898+ : resolveSearchApiKey ( search ) ;
738899
739900 if ( ! apiKey ) {
740901 const anthropicKey = resolveAnthropicApiKey ( ) ;
@@ -827,6 +988,8 @@ export function createWebSearchTool(options?: {
827988 perplexityModel : resolvePerplexityModel ( perplexityConfig ) ,
828989 grokModel : resolveGrokModel ( grokConfig ) ,
829990 grokInlineCitations : resolveGrokInlineCitations ( grokConfig ) ,
991+ exaContents : resolveExaContents ( exaConfig ) ,
992+ exaMaxChars : resolveExaMaxChars ( exaConfig ) ,
830993 } ) ;
831994 return jsonResult ( result ) ;
832995 } ,
@@ -840,4 +1003,7 @@ export const __testing = {
8401003 resolveGrokApiKey,
8411004 resolveGrokModel,
8421005 resolveGrokInlineCitations,
1006+ resolveExaApiKey,
1007+ resolveExaContents,
1008+ resolveExaMaxChars,
8431009} as const ;
0 commit comments