Skip to content

Commit 7016f85

Browse files
committed
feat(exa): refresh Exa integration against current API, retire dead research endpoint
Exa's dev-rel team flagged that our integration was written against a retired version of their API. Validated every claim against the live API with a real key. - /research/v1 returns HTTP 410 RESEARCH_RETIRED, so the Research operation was hard-broken in production. Removed it and added an Agent operation on /agent/runs. Saved workflows on the old operation are routed to Agent so they start working again. - Category dropdown sent values Exa no longer recognizes (research_paper, news_article, movie, song, ...). Exa accepts category as an unvalidated soft hint, so these silently stopped steering results rather than erroring. Replaced with the current taxonomy and remapped legacy values. - Live crawl mode defaulted to 'never', silently forcing cache-only results on every search. Removed the default and exposed maxAgeHours, which replaces the deprecated livecrawl. Exa 400s when both are sent, so they are now mutually exclusive. - numResults was capped at 25 in the UI; the API allows 1-100. - Search types refreshed to instant/fast/auto/deep-lite/deep/ deep-reasoning. Legacy neural/keyword still pass through. - Exposed result id so search results can be chained into Get Contents via ids, plus highlightScores, subpages, entities, extras, statuses, requestId, and outputSchema structured output with grounding. - answer text controls cited-source text, not the answer; fixed the description and the dead query field. - Marked findSimilar and the crawl-date filters deprecated. Both still work, so existing workflows are unaffected. - Copilot search-online never requested page content, so every snippet was empty. Now requests highlights.
1 parent 1a23438 commit 7016f85

15 files changed

Lines changed: 1483 additions & 570 deletions

File tree

apps/docs/content/docs/en/integrations/exa.mdx

Lines changed: 83 additions & 26 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/exa.ts

Lines changed: 319 additions & 139 deletions
Large diffs are not rendered by default.

apps/sim/lib/copilot/tools/server/other/search-online.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
4848
query,
4949
numResults: num,
5050
type: 'auto',
51+
// Exa omits page content unless it is requested, which would leave
52+
// every snippet empty. Highlights keep the payload small.
53+
highlights: true,
5154
apiKey: env.EXA_API_KEY ?? '',
5255
})
5356

@@ -58,6 +61,7 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
5861
url?: string
5962
text?: string
6063
summary?: string
64+
highlights?: string[]
6165
publishedDate?: string
6266
}>
6367
}
@@ -68,7 +72,7 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
6872
const transformedResults: SearchResult[] = exaResults.map((result, index) => ({
6973
title: result.title ?? '',
7074
link: result.url ?? '',
71-
snippet: result.text ?? result.summary ?? '',
75+
snippet: result.highlights?.join(' ') || result.text || result.summary || '',
7276
date: result.publishedDate,
7377
position: index + 1,
7478
}))

apps/sim/lib/integrations/integrations.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"updatedAt": "2026-07-29",
2+
"updatedAt": "2026-07-30",
33
"integrations": [
44
{
55
"type": "onepassword",
@@ -5929,7 +5929,7 @@
59295929
"slug": "exa",
59305930
"name": "Exa",
59315931
"description": "Search with Exa AI",
5932-
"longDescription": "Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research.",
5932+
"longDescription": "Integrate Exa into the workflow. Can search the web, get page contents, find similar links, answer a question with citations, and run deep research with Exa Agent.",
59335933
"bgColor": "#1F40ED",
59345934
"iconName": "ExaAIIcon",
59355935
"docsUrl": "https://docs.sim.ai/integrations/exa",
@@ -5942,17 +5942,17 @@
59425942
"name": "Get Contents",
59435943
"description": "Retrieve the contents of webpages using Exa AI. Returns the title, text content, and optional summaries for each URL."
59445944
},
5945-
{
5946-
"name": "Find Similar Links",
5947-
"description": "Find webpages similar to a given URL using Exa AI. Returns a list of similar links with titles and text snippets."
5948-
},
59495945
{
59505946
"name": "Answer",
59515947
"description": "Get an AI-generated answer to a question with citations from the web using Exa AI."
59525948
},
59535949
{
5954-
"name": "Research",
5955-
"description": "Perform comprehensive research using AI to generate detailed reports with citations"
5950+
"name": "Agent",
5951+
"description": "Run a deep research task with Exa Agent. Handles multi-step list building, enrichment, and research, returning a written answer with field-level citations and optional structured output."
5952+
},
5953+
{
5954+
"name": "Find Similar Links",
5955+
"description": "Find webpages similar to a given URL using Exa AI. Deprecated by Exa in favor of Search — prefer Search for new workflows."
59565956
}
59575957
],
59585958
"operationCount": 5,

apps/sim/tools/exa/agent.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { sleep } from '@sim/utils/helpers'
4+
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
5+
import type { ExaAgentParams, ExaAgentResponse } from '@/tools/exa/types'
6+
import { parseJsonSchema, requireCostTotal } from '@/tools/exa/utils'
7+
import type { ToolConfig } from '@/tools/types'
8+
9+
const logger = createLogger('ExaAgentTool')
10+
11+
const POLL_INTERVAL_MS = 3000
12+
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
13+
14+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
15+
16+
export const agentTool: ToolConfig<ExaAgentParams, ExaAgentResponse> = {
17+
id: 'exa_agent',
18+
name: 'Exa Agent',
19+
description:
20+
'Run a deep research task with Exa Agent. Handles multi-step list building, enrichment, and research, returning a written answer with field-level citations and optional structured output.',
21+
version: '1.0.0',
22+
23+
params: {
24+
query: {
25+
type: 'string',
26+
required: true,
27+
visibility: 'user-or-llm',
28+
description: 'The research question or instructions for the agent',
29+
},
30+
effort: {
31+
type: 'string',
32+
required: false,
33+
visibility: 'user-only',
34+
description:
35+
'Cost and depth tradeoff: minimal, low, medium, high, xhigh, or auto (default: auto)',
36+
},
37+
outputSchema: {
38+
type: 'json',
39+
required: false,
40+
visibility: 'user-or-llm',
41+
description:
42+
'JSON Schema describing the structured result to return. Returned in the structured output.',
43+
},
44+
systemPrompt: {
45+
type: 'string',
46+
required: false,
47+
visibility: 'user-or-llm',
48+
description: 'Additional guidance for how the agent should behave or format its answer',
49+
},
50+
previousRunId: {
51+
type: 'string',
52+
required: false,
53+
visibility: 'user-or-llm',
54+
description: 'ID of a completed agent run to continue from, for follow-up questions',
55+
},
56+
apiKey: {
57+
type: 'string',
58+
required: true,
59+
visibility: 'user-only',
60+
description: 'Exa AI API Key',
61+
},
62+
},
63+
hosting: {
64+
envKeyPrefix: 'EXA_API_KEY',
65+
apiKeyParam: 'apiKey',
66+
byokProviderId: 'exa',
67+
pricing: {
68+
type: 'custom',
69+
getCost: (_params, output) => {
70+
const cost = requireCostTotal(output, 'agent')
71+
return { cost, metadata: { costDollars: output.__costDollars } }
72+
},
73+
},
74+
rateLimit: {
75+
mode: 'per_request',
76+
requestsPerMinute: 5,
77+
},
78+
},
79+
80+
request: {
81+
url: 'https://api.exa.ai/agent/runs',
82+
method: 'POST',
83+
headers: (params) => ({
84+
'Content-Type': 'application/json',
85+
'x-api-key': params.apiKey,
86+
}),
87+
body: (params) => {
88+
const body: Record<string, any> = {
89+
query: params.query,
90+
}
91+
92+
if (params.effort) body.effort = params.effort
93+
if (params.systemPrompt) body.systemPrompt = params.systemPrompt
94+
if (params.previousRunId) body.previousRunId = params.previousRunId
95+
96+
const outputSchema = parseJsonSchema(params.outputSchema, 'outputSchema')
97+
if (outputSchema) body.outputSchema = outputSchema
98+
99+
return body
100+
},
101+
},
102+
103+
transformResponse: async (response: Response) => {
104+
const data = await response.json()
105+
106+
return {
107+
success: true,
108+
output: {
109+
runId: data.id,
110+
status: data.status,
111+
stopReason: data.stopReason,
112+
text: data.output?.text ?? '',
113+
structured: data.output?.structured ?? undefined,
114+
grounding: data.output?.grounding,
115+
__costDollars: data.costDollars,
116+
},
117+
}
118+
},
119+
120+
/**
121+
* Agent runs are asynchronous: the create call returns immediately with a
122+
* `queued` or `running` status, so poll the run until it reaches a terminal
123+
* status before handing results back to the workflow.
124+
*/
125+
postProcess: async (result, params) => {
126+
if (!result.success) return result
127+
128+
const runId = result.output.runId
129+
if (!runId) {
130+
return { ...result, success: false, error: 'Exa agent run did not return a run ID' }
131+
}
132+
133+
if (TERMINAL_STATUSES.has(result.output.status ?? '')) {
134+
return finalize(result)
135+
}
136+
137+
logger.info(`Exa agent run ${runId} created, polling for completion`)
138+
139+
let elapsedTime = 0
140+
141+
while (elapsedTime < MAX_POLL_TIME_MS) {
142+
await sleep(POLL_INTERVAL_MS)
143+
elapsedTime += POLL_INTERVAL_MS
144+
145+
try {
146+
const statusResponse = await fetch(`https://api.exa.ai/agent/runs/${runId}`, {
147+
method: 'GET',
148+
headers: {
149+
'x-api-key': params.apiKey,
150+
'Content-Type': 'application/json',
151+
},
152+
})
153+
154+
if (!statusResponse.ok) {
155+
throw new Error(`Failed to get agent run status: ${statusResponse.statusText}`)
156+
}
157+
158+
const runData = await statusResponse.json()
159+
160+
if (!TERMINAL_STATUSES.has(runData.status)) continue
161+
162+
result.output = {
163+
runId,
164+
status: runData.status,
165+
stopReason: runData.stopReason,
166+
text: runData.output?.text ?? '',
167+
structured: runData.output?.structured ?? undefined,
168+
grounding: runData.output?.grounding,
169+
__costDollars: runData.costDollars,
170+
}
171+
172+
if (runData.status !== 'completed') {
173+
return {
174+
...result,
175+
success: false,
176+
error: `Exa agent run ${runData.status}${runData.stopReason ? `: ${runData.stopReason}` : ''}`,
177+
}
178+
}
179+
180+
return finalize(result)
181+
} catch (error) {
182+
logger.error('Error polling Exa agent run status', {
183+
message: getErrorMessage(error, 'Unknown error'),
184+
runId,
185+
})
186+
187+
return {
188+
...result,
189+
success: false,
190+
error: `Error polling Exa agent run status: ${getErrorMessage(error, 'Unknown error')}`,
191+
}
192+
}
193+
}
194+
195+
logger.warn(
196+
`Exa agent run ${runId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`
197+
)
198+
return {
199+
...result,
200+
success: false,
201+
error: `Exa agent run did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`,
202+
}
203+
},
204+
205+
outputs: {
206+
runId: {
207+
type: 'string',
208+
description: 'Identifier of the agent run, reusable as previousRunId',
209+
},
210+
status: { type: 'string', description: 'Final status of the agent run' },
211+
stopReason: {
212+
type: 'string',
213+
description: 'Why the agent stopped, such as schema_satisfied',
214+
nullable: true,
215+
},
216+
text: { type: 'string', description: 'The written answer produced by the agent' },
217+
structured: {
218+
type: 'json',
219+
description: 'Structured result matching outputSchema, when one was supplied',
220+
optional: true,
221+
},
222+
grounding: {
223+
type: 'json',
224+
description: 'Field-level citations backing the agent output',
225+
optional: true,
226+
},
227+
},
228+
}
229+
230+
/**
231+
* A run that satisfies its schema can finish with an empty `text` body, so fall
232+
* back to the structured payload rather than returning a blank answer.
233+
*/
234+
function finalize(result: ExaAgentResponse): ExaAgentResponse {
235+
if (!result.output.text && result.output.structured !== undefined) {
236+
result.output.text = JSON.stringify(result.output.structured, null, 2)
237+
}
238+
return result
239+
}

0 commit comments

Comments
 (0)