diff --git a/README.md b/README.md
index d6a5e29e..5b0bfff9 100644
--- a/README.md
+++ b/README.md
@@ -43,13 +43,16 @@ Create a `.env.local` file with the following:
```env
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/workflow_builder
+Key should be a 32-byte hex string (64 characters)
+INTEGRATION_ENCRYPTION_KEY=your-encryption-key
# Better Auth
BETTER_AUTH_SECRET=your-secret-key
BETTER_AUTH_URL=http://localhost:3000
-# AI Gateway (for AI workflow generation)
-AI_GATEWAY_API_KEY=your-openai-api-key
+# AI Gateway (for AI workflow generation, see more https://vercel.com/ai-gateway)
+AI_GATEWAY_API_KEY=your-ai-gateway-api-key
+
```
### Installation
@@ -81,6 +84,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.
- **AI Gateway**: Generate Text, Generate Image
- **Blob**: Put Blob, List Blobs
+- **Exa**: Search Web
- **fal.ai**: Generate Image, Generate Video, Upscale Image, Remove Background, Image to Image
- **Firecrawl**: Scrape URL, Search Web
- **GitHub**: Create Issue, List Issues, Get Issue, Update Issue
diff --git a/package.json b/package.json
index b8d2c87f..6ad82774 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"discover-plugins": "tsx scripts/discover-plugins.ts",
- "create-plugin": "tsx scripts/create-plugin.ts"
+ "create-plugin": "tsx scripts/create-plugin.ts",
+ "workflow:runs:web": "npx workflow inspect runs --web"
},
"dependencies": {
"@ai-sdk/provider": "^2.0.0",
@@ -37,6 +38,7 @@
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
+ "exa-js": "^2.0.11",
"jotai": "^2.15.1",
"jszip": "^3.10.1",
"lucide-react": "^0.552.0",
diff --git a/plugins/exa/credentials.ts b/plugins/exa/credentials.ts
new file mode 100644
index 00000000..f40ade58
--- /dev/null
+++ b/plugins/exa/credentials.ts
@@ -0,0 +1,4 @@
+export type ExaCredentials = {
+ EXA_API_KEY?: string;
+};
+
diff --git a/plugins/exa/icon.tsx b/plugins/exa/icon.tsx
new file mode 100644
index 00000000..f642f426
--- /dev/null
+++ b/plugins/exa/icon.tsx
@@ -0,0 +1,31 @@
+export function ExaIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/plugins/exa/index.ts b/plugins/exa/index.ts
new file mode 100644
index 00000000..84d8182d
--- /dev/null
+++ b/plugins/exa/index.ts
@@ -0,0 +1,84 @@
+import type { IntegrationPlugin } from "../registry";
+import { registerIntegration } from "../registry";
+import { ExaIcon } from "./icon";
+
+const exaPlugin: IntegrationPlugin = {
+ type: "exa",
+ label: "Exa",
+ description:
+ "Semantic web search API giving AI apps fast, relevant, up-to-date results",
+
+ icon: ExaIcon,
+
+ formFields: [
+ {
+ id: "exaApiKey",
+ label: "API Key",
+ type: "password",
+ placeholder: "Your Exa API key",
+ configKey: "exaApiKey",
+ envVar: "EXA_API_KEY",
+ helpText: "Get your API key from ",
+ helpLink: {
+ text: "Exa Dashboard",
+ url: "https://dashboard.exa.ai/api-keys/",
+ },
+ },
+ ],
+
+ testConfig: {
+ getTestFunction: async () => {
+ const { testExa } = await import("./test");
+ return testExa;
+ },
+ },
+
+ actions: [
+ {
+ slug: "search",
+ label: "Search Web",
+ description:
+ "Perform semantic web search and retrieve relevant results with content",
+ category: "Exa",
+ stepFunction: "exaSearchStep",
+ stepImportPath: "search",
+ outputFields: [
+ { field: "results", description: "Array of search results" },
+ ],
+ configFields: [
+ {
+ key: "query",
+ label: "Search Query",
+ type: "template-input",
+ placeholder: "Search query or {{NodeName.query}}",
+ example: "latest AI research papers",
+ required: true,
+ },
+ {
+ key: "numResults",
+ label: "Number of Results",
+ type: "number",
+ placeholder: "10",
+ min: 1,
+ example: "10",
+ },
+ {
+ key: "type",
+ label: "Search Type",
+ type: "select",
+ options: [
+ { value: "auto", label: "Auto" },
+ { value: "neural", label: "Neural" },
+ { value: "keyword", label: "Keyword" },
+ ],
+ defaultValue: "auto",
+ },
+ ],
+ },
+ ],
+};
+
+// Auto-register on import
+registerIntegration(exaPlugin);
+
+export default exaPlugin;
diff --git a/plugins/exa/steps/search.ts b/plugins/exa/steps/search.ts
new file mode 100644
index 00000000..65e9b078
--- /dev/null
+++ b/plugins/exa/steps/search.ts
@@ -0,0 +1,129 @@
+import "server-only";
+
+import { fetchCredentials } from "@/lib/credential-fetcher";
+import { type StepInput, withStepLogging } from "@/lib/steps/step-handler";
+import type { ExaCredentials } from "../credentials";
+
+const EXA_API_URL = "https://api.exa.ai";
+
+type ExaSearchResponse = {
+ results: Array<{
+ url: string;
+ id: string;
+ title: string | null;
+ publishedDate?: string;
+ author?: string;
+ text?: string;
+ }>;
+ autopromptString?: string;
+};
+
+type ExaErrorResponse = {
+ error?: string;
+ message?: string;
+};
+
+type SearchResult =
+ | {
+ success: true;
+ results: Array<{
+ url: string;
+ title: string | null;
+ publishedDate?: string;
+ author?: string;
+ text?: string;
+ }>;
+ }
+ | { success: false; error: string };
+
+export type SearchCoreInput = {
+ query: string;
+ numResults?: number;
+ type?: "auto" | "neural" | "keyword";
+};
+
+export type ExaSearchInput = StepInput &
+ SearchCoreInput & {
+ integrationId?: string;
+ };
+
+/**
+ * Core logic - portable between app and export
+ */
+async function stepHandler(
+ input: SearchCoreInput,
+ credentials: ExaCredentials
+): Promise {
+ const apiKey = credentials.EXA_API_KEY;
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error:
+ "EXA_API_KEY is not configured. Please add it in Project Integrations.",
+ };
+ }
+
+ try {
+ const response = await fetch(`${EXA_API_URL}/search`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": apiKey,
+ },
+ body: JSON.stringify({
+ query: input.query,
+ numResults: input.numResults ? Number(input.numResults) : 10,
+ type: input.type || "auto",
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = (await response.json()) as ExaErrorResponse;
+ return {
+ success: false,
+ error:
+ errorData.error ||
+ errorData.message ||
+ `HTTP ${response.status}: Search failed`,
+ };
+ }
+
+ const data = (await response.json()) as ExaSearchResponse;
+
+ return {
+ success: true,
+ results: data.results.map((r) => ({
+ url: r.url,
+ title: r.title,
+ publishedDate: r.publishedDate,
+ author: r.author,
+ text: r.text,
+ })),
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ success: false,
+ error: `Failed to search: ${message}`,
+ };
+ }
+}
+
+/**
+ * App entry point - fetches credentials and wraps with logging
+ */
+export async function exaSearchStep(
+ input: ExaSearchInput
+): Promise {
+ "use step";
+
+ const credentials = input.integrationId
+ ? await fetchCredentials(input.integrationId)
+ : {};
+
+ return withStepLogging(input, () => stepHandler(input, credentials));
+}
+
+// Export marker for codegen auto-generation
+export const _integrationType = "exa";
diff --git a/plugins/exa/test.ts b/plugins/exa/test.ts
new file mode 100644
index 00000000..093c2a16
--- /dev/null
+++ b/plugins/exa/test.ts
@@ -0,0 +1,42 @@
+export async function testExa(credentials: Record) {
+ try {
+ const apiKey = credentials.EXA_API_KEY;
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error: "EXA_API_KEY is required",
+ };
+ }
+
+ // Use a minimal search request to validate the API key
+ const response = await fetch("https://api.exa.ai/search", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": apiKey,
+ },
+ body: JSON.stringify({
+ query: "test",
+ numResults: 1,
+ type: "keyword",
+ }),
+ });
+
+ if (response.ok) {
+ return { success: true };
+ }
+
+ if (response.status === 401) {
+ return { success: false, error: "Invalid API key" };
+ }
+
+ const error = await response.text();
+ return { success: false, error: error || `API error: HTTP ${response.status}` };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
diff --git a/plugins/index.ts b/plugins/index.ts
index c2b41249..fce9e89e 100644
--- a/plugins/index.ts
+++ b/plugins/index.ts
@@ -16,6 +16,7 @@
import "./ai-gateway";
import "./blob";
+import "./exa";
import "./fal";
import "./firecrawl";
import "./github";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c09e4d01..483f2a7e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,6 +62,9 @@ importers:
drizzle-orm:
specifier: ^0.44.7
version: 0.44.7(@opentelemetry/api@1.9.0)(kysely@0.28.8)(postgres@3.4.7)
+ exa-js:
+ specifier: ^2.0.11
+ version: 2.0.11(ws@8.18.3)
jotai:
specifier: ^2.15.1
version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
@@ -2864,6 +2867,9 @@ packages:
typescript:
optional: true
+ cross-fetch@4.1.0:
+ resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2984,6 +2990,10 @@ packages:
dompurify@3.1.7:
resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==}
+ dotenv@16.4.7:
+ resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
+ engines: {node: '>=12'}
+
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -3200,6 +3210,9 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
+ exa-js@2.0.11:
+ resolution: {integrity: sha512-xMZjtZQ9dqhHCFspWhq9mQFF7H+hJw1yQE2wKS99ZuzO3JrqzqxbPK60BjNhkiMqiD/n+06O1fuLZs/HAqeaEg==}
+
execa@9.6.0:
resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -3873,6 +3886,18 @@ packages:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
+ openai@5.23.2:
+ resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==}
+ hasBin: true
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.23.8
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
openai@6.8.1:
resolution: {integrity: sha512-ACifslrVgf+maMz9vqwMP4+v9qvx5Yzssydizks8n+YUJ6YwUoxj51sKRQ8HYMfR6wgKLSIlaI108ZwCk+8yig==}
hasBin: true
@@ -7686,6 +7711,12 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ cross-fetch@4.1.0:
+ dependencies:
+ node-fetch: 2.7.0
+ transitivePeerDependencies:
+ - encoding
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -7786,6 +7817,8 @@ snapshots:
dompurify@3.1.7: {}
+ dotenv@16.4.7: {}
+
dotenv@17.2.3: {}
drizzle-kit@0.31.6:
@@ -7949,6 +7982,17 @@ snapshots:
eventsource-parser@3.0.6: {}
+ exa-js@2.0.11(ws@8.18.3):
+ dependencies:
+ cross-fetch: 4.1.0
+ dotenv: 16.4.7
+ openai: 5.23.2(ws@8.18.3)(zod@3.25.76)
+ zod: 3.25.76
+ zod-to-json-schema: 3.25.0(zod@3.25.76)
+ transitivePeerDependencies:
+ - encoding
+ - ws
+
execa@9.6.0:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -8542,6 +8586,11 @@ snapshots:
is-inside-container: 1.0.0
wsl-utils: 0.1.0
+ openai@5.23.2(ws@8.18.3)(zod@3.25.76):
+ optionalDependencies:
+ ws: 8.18.3
+ zod: 3.25.76
+
openai@6.8.1(ws@8.18.3)(zod@4.1.12):
optionalDependencies:
ws: 8.18.3