Skip to content

Commit 911b958

Browse files
authored
feat(exa): refresh Exa integration against current API, retire dead research endpoint (#6074)
* 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. * fix(exa): address review findings on the API refresh - A run already terminal on creation went through a path that never set success=false, so a failed or cancelled run reported as successful. Both the create path and the poll loop now settle through one function. - Routing exa_research to Agent dropped the research output shape, so saved workflows referencing research[0].text resolved to undefined. The agent tool now also emits that legacy shape. - The Agent operation's inputs are conditioned on both exa_agent and exa_research so the serializer keeps carrying a stored research query; it drops any value whose sub-block condition no longer matches. - Dropped the model to effort mapping and the unused ExaResearchParams: the serializer drops values for removed sub-blocks, so model never reached the params function. * fix(exa): keep legacy research model, add subblock migrations, sharpen outputs The subblock ID stability check caught the removed subblocks — that gate exists precisely to stop removals from breaking deployed workflows. - Restore the research model sub-block, scoped to the legacy exa_research operation so it never shows for new workflows but still serializes for saved ones, and restore the model to effort mapping. Removing it lost the configured research depth, silently falling back to effort auto. - Register useAutoprompt and livecrawl in SUBBLOCK_ID_MIGRATIONS as intentional removals. Neither has a value-compatible replacement: livecrawl is a mode string and maxAgeHours a number, so mapping one to the other would send NaN. - Replace vague json output descriptions with their inner field lists. - Drop the separate compat test file; the coverage that guards real regressions now lives in exa.test.ts. * fix(exa): flag empty Get Contents configs in the editor, keep legacy research depth - A saved research workflow with no stored model fell through to the Agent default of auto rather than the standard depth the old Research operation used. Legacy research now always maps to an effort level, defaulting to medium, and the legacy model sub-block carries the same default the old dropdown had. - Get Contents needed both selectors optional so the ids path is reachable, which left an empty config failing only at run time. URLs is now conditionally required, dropping the requirement when result IDs are supplied, so the editor flags the empty case. The exactly-one check in the request body stays as the backstop. * fix(exa): revert conditional required on Get Contents URLs The conditional required callback did not work and introduced a regression. `isFieldRequired` in webhook deploy calls `config.required()` with no arguments, so the callback never saw `ids` and left URLs required — an ids-only block would have been reported as missing a required field on deploy. `collectBlockFieldIssues` skips sub-block required checks whose id matches a tool param, so it never evaluated the callback either. Both selectors go back to optional with the exactly-one check in the request body, which is what the integration rules prescribe for mutually exclusive alternate identifiers. Added a comment recording why a conditional required cannot express this, so it is not reattempted.
1 parent f0b79c5 commit 911b958

16 files changed

Lines changed: 1674 additions & 567 deletions

File tree

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

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

apps/sim/blocks/blocks/exa.ts

Lines changed: 382 additions & 141 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/lib/workflows/migrations/subblock-migrations.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
6565
stage_ids: '_removed_stage_ids',
6666
owner_ids: '_removed_owner_ids',
6767
},
68+
exa: {
69+
/**
70+
* Exa deprecated both fields. `useAutoprompt` is gone from the API, and
71+
* `livecrawl` is superseded by `maxAgeHours` — but their values are not
72+
* interchangeable (`livecrawl` is a mode string, `maxAgeHours` a number),
73+
* so mapping one onto the other would send `NaN`. Dropping `livecrawl` is
74+
* also the fix for the block having defaulted it to `never`, which pinned
75+
* every saved search to cached results.
76+
*/
77+
useAutoprompt: '_removed_useAutoprompt',
78+
livecrawl: '_removed_livecrawl',
79+
},
6880
rippling: {
6981
action: '_removed_action',
7082
candidateDepartment: '_removed_candidateDepartment',

apps/sim/tools/exa/agent.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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+
/** A run can already be terminal on creation, including a failed one. */
134+
if (TERMINAL_STATUSES.has(result.output.status ?? '')) {
135+
return settle(result)
136+
}
137+
138+
logger.info(`Exa agent run ${runId} created, polling for completion`)
139+
140+
let elapsedTime = 0
141+
142+
while (elapsedTime < MAX_POLL_TIME_MS) {
143+
await sleep(POLL_INTERVAL_MS)
144+
elapsedTime += POLL_INTERVAL_MS
145+
146+
try {
147+
const statusResponse = await fetch(`https://api.exa.ai/agent/runs/${runId}`, {
148+
method: 'GET',
149+
headers: {
150+
'x-api-key': params.apiKey,
151+
'Content-Type': 'application/json',
152+
},
153+
})
154+
155+
if (!statusResponse.ok) {
156+
throw new Error(`Failed to get agent run status: ${statusResponse.statusText}`)
157+
}
158+
159+
const runData = await statusResponse.json()
160+
161+
if (!TERMINAL_STATUSES.has(runData.status)) continue
162+
163+
result.output = {
164+
runId,
165+
status: runData.status,
166+
stopReason: runData.stopReason,
167+
text: runData.output?.text ?? '',
168+
structured: runData.output?.structured ?? undefined,
169+
grounding: runData.output?.grounding,
170+
__costDollars: runData.costDollars,
171+
}
172+
173+
return settle(result)
174+
} catch (error) {
175+
logger.error('Error polling Exa agent run status', {
176+
message: getErrorMessage(error, 'Unknown error'),
177+
runId,
178+
})
179+
180+
return {
181+
...result,
182+
success: false,
183+
error: `Error polling Exa agent run status: ${getErrorMessage(error, 'Unknown error')}`,
184+
}
185+
}
186+
}
187+
188+
logger.warn(
189+
`Exa agent run ${runId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`
190+
)
191+
return {
192+
...result,
193+
success: false,
194+
error: `Exa agent run did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`,
195+
}
196+
},
197+
198+
outputs: {
199+
runId: {
200+
type: 'string',
201+
description: 'Identifier of the agent run, reusable as previousRunId',
202+
},
203+
status: { type: 'string', description: 'Final status of the agent run' },
204+
stopReason: {
205+
type: 'string',
206+
description: 'Why the agent stopped, such as schema_satisfied',
207+
nullable: true,
208+
},
209+
text: { type: 'string', description: 'The written answer produced by the agent' },
210+
structured: {
211+
type: 'json',
212+
description: 'Structured result matching outputSchema, when one was supplied',
213+
optional: true,
214+
},
215+
grounding: {
216+
type: 'json',
217+
description: 'Field-level citations backing the agent output',
218+
optional: true,
219+
},
220+
research: {
221+
type: 'array',
222+
description:
223+
'The agent answer in the shape the retired Research operation emitted, so workflows that reference it keep resolving',
224+
items: {
225+
type: 'object',
226+
properties: {
227+
title: { type: 'string' },
228+
url: { type: 'string' },
229+
summary: { type: 'string' },
230+
text: { type: 'string' },
231+
score: { type: 'number' },
232+
},
233+
},
234+
},
235+
},
236+
}
237+
238+
/**
239+
* Resolves a terminal run into a tool result.
240+
*
241+
* A run can reach a terminal status either on creation or while polling, and a
242+
* `failed` or `cancelled` run must surface as a tool failure from both paths —
243+
* routing them through here keeps the two in step.
244+
*/
245+
function settle(result: ExaAgentResponse): ExaAgentResponse {
246+
const { status, stopReason } = result.output
247+
248+
if (status !== 'completed') {
249+
return {
250+
...result,
251+
success: false,
252+
error: `Exa agent run ${status}${stopReason ? `: ${stopReason}` : ''}`,
253+
}
254+
}
255+
256+
/**
257+
* A run that satisfies its schema can finish with an empty `text` body, so
258+
* fall back to the structured payload rather than returning a blank answer.
259+
*/
260+
if (!result.output.text && result.output.structured !== undefined) {
261+
result.output.text = JSON.stringify(result.output.structured, null, 2)
262+
}
263+
264+
result.output.research = buildLegacyResearchOutput(result.output.text)
265+
266+
return result
267+
}
268+
269+
/**
270+
* Mirrors the one-element array the retired Research operation returned. Saved
271+
* workflows routed here from `exa_research` reference `research[0].text` and
272+
* `research[0].summary`, which would otherwise resolve to undefined.
273+
*/
274+
function buildLegacyResearchOutput(text: string) {
275+
return [
276+
{
277+
title: 'Research Complete',
278+
url: '',
279+
summary: text,
280+
text,
281+
score: 1,
282+
},
283+
]
284+
}

0 commit comments

Comments
 (0)