snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,20 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.json"],
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,37 @@
# Exa
Structured web search powered by [Exa](https://exa.ai), available to Twenty's AI agents as the `exa_web_search` tool. When an agent needs fresh, entity-aware information from the web (companies, people, research, news, and more), it can call Exa directly from chat and answer with live results.
## What it does
This app adds a single AI tool, `exa_web_search`, to your workspace. Twenty AI agents can invoke it to run a search through Exa and receive structured, entity-aware results. It is designed to surface high-quality matches for companies, people, research papers, news, and similar entities rather than generic page links.
The tool reads no workspace data. It only uses the search query the agent provides and your configured Exa API key, calling Exa's external API to return results.
## How agents use it
The tool accepts the following inputs:
- **query** (required) — the search query to send to Exa.
- **category** (optional) — narrows results to a specific type. Supported values: `company`, `research paper`, `news`, `pdf`, `personal site`, `financial report`, `people`. When omitted, Exa searches across all types.
- **numResults** (optional) — how many results to return, between `1` and `30`. Defaults to `10`.
Agents choose these values automatically based on what they are trying to find, so no manual configuration is needed once the app is set up.
## Setup
The app requires an Exa API key, configured once by the server administrator:
- **EXA_API_KEY** (required, secret) — your Exa API key. It is set on the app after installation and injected into every search. There is no per-workspace configuration; the same key serves all searches.
If the key is not set, searches fail with an error until an administrator provides it.
## Billing
Each successful search consumes credits, mirroring Exa's auto-search pricing: a base cost of **$0.007** covers the first 10 results, plus **$0.001** for each additional result. Charges are based on the number of results actually returned, not the number requested.
## Limitations
- Returns at most 30 results per search.
- Results come live from Exa's external API, so the app depends on Exa's availability and on a valid API key being configured.
- The tool only performs web search; it does not read from or write to your workspace data.

View File

@@ -0,0 +1,38 @@
{
"name": "@twentyhq/exa",
"version": "0.2.0",
"description": "Structured web search powered by Exa, exposed to Twenty AI agents as a tool.",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.19.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"dependencies": {
"exa-js": "^2.12.1"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"twenty-client-sdk": "2.37.0",
"twenty-sdk": "2.37.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

View File

@@ -0,0 +1,6 @@
<svg width="1386" height="480" viewBox="0 0 1386 480" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1247.78 71.5781C1289.16 71.5781 1321.17 82.4448 1343.82 104.178C1366.9 125.477 1378.44 157.425 1378.44 200.023V327.163C1378.44 358.182 1380.69 384.72 1385.18 406.779C1385.71 409.423 1383.71 411.924 1381.01 411.924H1337.41C1335.43 411.924 1333.71 410.55 1333.33 408.61C1330.74 395.478 1329.44 380.499 1329.44 363.675H1328.14C1304.18 400.622 1265.86 419.096 1213.16 419.096C1179.19 419.096 1151.96 410.185 1131.49 392.364C1111.02 374.108 1100.79 350.635 1100.79 321.947C1100.79 293.259 1110.15 270.656 1128.88 254.139C1148.04 237.187 1180.71 225.016 1226.88 217.627C1258.22 212.554 1289.36 208.92 1320.29 206.725C1321.77 206.621 1322.91 205.398 1322.91 203.924V193.503C1322.91 166.988 1316.16 147.428 1302.66 134.822C1289.59 122.217 1271.3 115.914 1247.78 115.914C1224.26 115.914 1205.97 122 1192.9 134.17C1180.73 145.109 1173.85 159.634 1172.26 177.746C1172.06 179.99 1170.22 181.767 1167.96 181.767H1118.95C1116.54 181.767 1114.61 179.739 1114.81 177.337C1117.41 146.757 1129.72 121.718 1151.75 102.222C1174.83 81.7928 1206.84 71.5781 1247.78 71.5781ZM1322.91 270.439V254.774C1322.91 252.322 1320.82 250.389 1318.37 250.588C1286.75 253.153 1259.3 256.51 1236.02 260.659C1208.15 265.006 1188.33 271.526 1176.57 280.219C1165.25 288.912 1159.59 301.518 1159.59 318.035C1159.59 335.422 1165.47 349.331 1177.23 359.763C1189.42 369.761 1206.19 374.76 1227.53 374.76C1256.71 374.76 1279.58 366.066 1296.13 348.679C1306.14 338.247 1313.11 327.598 1317.03 316.731C1320.95 305.43 1322.91 289.999 1322.91 270.439Z" fill="black"/>
<path d="M1069.28 80.7062C1072.7 80.7062 1074.69 84.56 1072.72 87.3456L964.411 239.795L1083.11 405.269C1085.11 408.052 1083.11 411.924 1079.68 411.924H1020.88C1019.48 411.924 1018.17 411.224 1017.38 410.059L929.786 279.567H928.479L840.882 410.059C840.1 411.224 838.786 411.924 837.38 411.924H779.234C775.804 411.924 773.81 408.052 775.807 405.269L894.507 239.795L786.203 87.3456C784.224 84.5601 786.22 80.7062 789.641 80.7062H847.17C848.581 80.7062 849.899 81.411 850.68 82.5838L928.479 199.371H929.786L1008.24 82.5705C1009.02 81.4053 1010.33 80.7062 1011.74 80.7062H1069.28Z" fill="black"/>
<path d="M602.456 376.716C625.975 376.716 645.356 370.848 660.6 359.112C675.353 348.069 684.901 333.754 689.242 316.169C689.721 314.229 691.436 312.819 693.438 312.819H743.669C746.324 312.819 748.321 315.243 747.711 317.822C740.936 346.486 725.4 370.681 701.105 390.408C675.844 410.837 642.961 421.052 602.456 421.052C554.111 421.052 516.002 404.969 488.128 372.804C460.254 340.203 446.316 296.519 446.316 241.751C446.316 191.764 460.036 150.905 487.474 119.174C515.349 87.4435 553.023 71.5781 600.496 71.5781C637.516 71.5781 668.657 81.7928 693.918 102.222C719.615 122.652 736.818 150.905 745.529 186.983C749.676 203.95 751.848 226.236 752.047 253.84C752.064 256.163 750.175 258.051 747.847 258.051H505.986C504.794 258.051 503.839 259.042 503.889 260.231C505.474 298.271 515.029 327.102 532.552 346.723C550.845 366.718 574.146 376.716 602.456 376.716ZM600.496 115.914C573.493 115.914 551.498 124.608 534.512 141.994C518.087 158.387 508.178 181.487 504.785 211.293C504.598 212.938 505.897 214.367 507.557 214.367H692.133C693.79 214.367 695.09 212.941 694.906 211.297C691.518 181.062 681.607 157.744 665.173 141.342C648.187 124.39 626.628 115.914 600.496 115.914Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 475.765C0 478.104 1.88926 480 4.21978 480H389.626C391.957 480 393.846 478.104 393.846 475.765V455.637C393.846 453.672 393.166 451.767 391.922 450.248L221.889 242.69C220.609 241.127 220.609 238.873 221.889 237.31L391.922 29.7517C393.166 28.2333 393.846 26.3285 393.846 24.3627V4.23529C393.846 1.89621 391.957 0 389.626 0H4.21978C1.88926 0 0 1.8962 0 4.23529V475.765ZM199.219 270.589C198.088 269.208 195.98 269.216 194.859 270.605L58.1475 440.106C56.6585 441.952 57.9676 444.706 60.334 444.706H335.902C338.278 444.706 339.584 441.932 338.075 440.089L199.219 270.589ZM38.9146 407.858C37.6636 409.409 35.1648 408.521 35.1648 406.525V260.471C35.1648 258.911 36.4244 257.647 37.978 257.647H154.171C156.538 257.647 157.847 260.401 156.358 262.247L38.9146 407.858ZM154.171 222.353C156.538 222.353 157.847 219.599 156.358 217.753L38.9146 72.1424C37.6636 70.5913 35.1648 71.4792 35.1648 73.4749V219.529C35.1648 221.089 36.4243 222.353 37.978 222.353H154.171ZM58.1475 39.8942C56.6585 38.0482 57.9676 35.2941 60.334 35.2941H335.902C338.278 35.2941 339.584 38.0685 338.075 39.9109L199.219 209.411C198.088 210.792 195.98 210.784 194.859 209.395L58.1475 39.8942Z" fill="#2043EC"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,5 @@
<svg width="395" height="480" viewBox="0 0 395 480" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 475.765C0 478.104 1.8948 480 4.23215 480H390.768C393.105 480 395 478.104 395 475.765V455.637C395 453.672 394.318 451.767 393.07 450.248L222.539 242.69C221.255 241.127 221.255 238.873 222.539 237.31L393.07 29.7517C394.318 28.2333 395 26.3285 395 24.3627V4.23529C395 1.89621 393.105 0 390.768 0H4.23214C1.89479 0 0 1.8962 0 4.23529V475.765ZM199.802 270.589C198.668 269.208 196.554 269.216 195.43 270.605L58.3178 440.106C56.8245 441.952 58.1374 444.706 60.5108 444.706H336.886C339.269 444.706 340.579 441.932 339.065 440.089L199.802 270.589ZM39.0286 407.858C37.7739 409.409 35.2679 408.521 35.2679 406.525V260.471C35.2679 258.911 36.5311 257.647 38.0893 257.647H154.623C156.996 257.647 158.309 260.401 156.816 262.247L39.0286 407.858ZM154.623 222.353C156.996 222.353 158.309 219.599 156.816 217.753L39.0286 72.1424C37.7739 70.5913 35.2679 71.4792 35.2679 73.4749V219.529C35.2679 221.089 36.5311 222.353 38.0893 222.353H154.623ZM58.3178 39.8942C56.8245 38.0482 58.1374 35.2941 60.5108 35.2941H336.886C339.269 35.2941 340.579 38.0685 339.065 39.9109L199.802 209.411C198.668 210.792 196.554 210.784 195.43 209.395L58.3178 39.8942Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 475.765C0 478.104 1.8948 480 4.23215 480H390.768C393.105 480 395 478.104 395 475.765V455.637C395 453.672 394.318 451.767 393.07 450.248L222.539 242.69C221.255 241.127 221.255 238.873 222.539 237.31L393.07 29.7517C394.318 28.2333 395 26.3285 395 24.3627V4.23529C395 1.89621 393.105 0 390.768 0H4.23214C1.89479 0 0 1.8962 0 4.23529V475.765ZM199.802 270.589C198.668 269.208 196.554 269.216 195.43 270.605L58.3178 440.106C56.8245 441.952 58.1374 444.706 60.5108 444.706H336.886C339.269 444.706 340.579 441.932 339.065 440.089L199.802 270.589ZM39.0286 407.858C37.7739 409.409 35.2679 408.521 35.2679 406.525V260.471C35.2679 258.911 36.5311 257.647 38.0893 257.647H154.623C156.996 257.647 158.309 260.401 156.816 262.247L39.0286 407.858ZM154.623 222.353C156.996 222.353 158.309 219.599 156.816 217.753L39.0286 72.1424C37.7739 70.5913 35.2679 71.4792 35.2679 73.4749V219.529C35.2679 221.089 36.5311 222.353 38.0893 222.353H154.623ZM58.3178 39.8942C56.8245 38.0482 58.1374 35.2941 60.5108 35.2941H336.886C339.269 35.2941 340.579 38.0685 339.065 39.9109L199.802 209.411C198.668 210.792 196.554 210.784 195.43 209.395L58.3178 39.8942Z" fill="#111827"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 475.765C0 478.104 1.8948 480 4.23215 480H390.768C393.105 480 395 478.104 395 475.765V455.637C395 453.672 394.318 451.767 393.07 450.248L222.539 242.69C221.255 241.127 221.255 238.873 222.539 237.31L393.07 29.7517C394.318 28.2333 395 26.3285 395 24.3627V4.23529C395 1.89621 393.105 0 390.768 0H4.23214C1.89479 0 0 1.8962 0 4.23529V475.765ZM199.802 270.589C198.668 269.208 196.554 269.216 195.43 270.605L58.3178 440.106C56.8245 441.952 58.1374 444.706 60.5108 444.706H336.886C339.269 444.706 340.579 441.932 339.065 440.089L199.802 270.589ZM39.0286 407.858C37.7739 409.409 35.2679 408.521 35.2679 406.525V260.471C35.2679 258.911 36.5311 257.647 38.0893 257.647H154.623C156.996 257.647 158.309 260.401 156.816 262.247L39.0286 407.858ZM154.623 222.353C156.996 222.353 158.309 219.599 156.816 217.753L39.0286 72.1424C37.7739 70.5913 35.2679 71.4792 35.2679 73.4749V219.529C35.2679 221.089 36.5311 222.353 38.0893 222.353H154.623ZM58.3178 39.8942C56.8245 38.0482 58.1374 35.2941 60.5108 35.2941H336.886C339.269 35.2941 340.579 38.0685 339.065 39.9109L199.802 209.411C198.668 210.792 196.554 210.784 195.43 209.395L58.3178 39.8942Z" fill="#1E40ED"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View File

@@ -0,0 +1,25 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
describe('App installation', () => {
it('should find the installed Exa app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const matchingApplication = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(matchingApplication).toBeDefined();
});
});

View File

@@ -0,0 +1,29 @@
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'2b7f4a2e-9c4b-4a11-b63c-2e5e7d3f5a9a';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Exa',
description:
'Structured web search powered by Exa. Surfaces entity-aware results (companies, people, research, news) to Twenty AI agents.',
author: 'Twenty',
category: 'Search',
logoUrl: 'public/exa-logomark.svg',
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
serverVariables: {
EXA_API_KEY: {
description:
'Exa API key. Set by the server admin on this registration after installation; the value is injected into every logic function execution.',
isSecret: true,
isRequired: true,
},
},
});

View File

@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type ExaWebSearchInput } from '../types/exa-web-search-input.type';
const { exaConstructorMock, searchAndContentsMock, chargeCreditsMock } =
vi.hoisted(() => ({
exaConstructorMock: vi.fn(),
searchAndContentsMock: vi.fn(),
chargeCreditsMock: vi.fn(),
}));
vi.mock('exa-js', () => ({
default: vi.fn(function (apiKey: string) {
exaConstructorMock(apiKey);
return { searchAndContents: searchAndContentsMock };
}),
}));
vi.mock('twenty-sdk/billing', () => ({
chargeCredits: chargeCreditsMock,
}));
import exaWebSearch from '../exa-web-search';
type ExaWebSearchResult = {
success: boolean;
message: string;
result?: { title: string; url: string; snippet: string }[];
error?: string;
};
const handler = exaWebSearch.config.handler as (
parameters: ExaWebSearchInput,
) => Promise<ExaWebSearchResult>;
const API_KEY = 'exa-test-key';
describe('exa_web_search handler', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.EXA_API_KEY = API_KEY;
chargeCreditsMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return a configuration error and skip the search when EXA_API_KEY is not set', async () => {
delete process.env.EXA_API_KEY;
const result = await handler({ query: 'twenty crm' });
expect(result.success).toBe(false);
expect(result.message).toBe('Exa is not configured');
expect(result.error).toContain('EXA_API_KEY is not set');
expect(exaConstructorMock).not.toHaveBeenCalled();
expect(searchAndContentsMock).not.toHaveBeenCalled();
expect(chargeCreditsMock).not.toHaveBeenCalled();
});
it('should map Exa results to title/url/snippet and report success', async () => {
searchAndContentsMock.mockResolvedValue({
results: [
{
title: 'Twenty CRM',
url: 'https://twenty.com',
highlights: ['Open-source CRM', 'Built with modern tech'],
},
],
});
const result = await handler({ query: 'twenty crm' });
expect(exaConstructorMock).toHaveBeenCalledWith(API_KEY);
expect(result).toEqual({
success: true,
message: 'Found 1 results for "twenty crm"',
result: [
{
title: 'Twenty CRM',
url: 'https://twenty.com',
snippet: 'Open-source CRM\nBuilt with modern tech',
},
],
});
});
it('should default to DEFAULT_NUM_RESULTS and search with type "auto" and highlights when numResults is omitted', async () => {
searchAndContentsMock.mockResolvedValue({ results: [] });
await handler({ query: 'acme corp', category: 'company' });
expect(searchAndContentsMock).toHaveBeenCalledWith('acme corp', {
type: 'auto',
numResults: 10,
category: 'company',
highlights: { numSentences: 5 },
});
});
it('should forward an explicit numResults to Exa', async () => {
searchAndContentsMock.mockResolvedValue({ results: [] });
await handler({ query: 'acme corp', numResults: 3 });
expect(searchAndContentsMock).toHaveBeenCalledWith(
'acme corp',
expect.objectContaining({ numResults: 3 }),
);
});
it('should include the category in the success message when one is provided', async () => {
searchAndContentsMock.mockResolvedValue({
results: [{ title: 'A', url: 'https://a.com', highlights: ['x'] }],
});
const result = await handler({ query: 'openai', category: 'company' });
expect(result.message).toBe('Found 1 results for "openai" (category: company)');
});
it('should fall back to empty strings for a missing title and missing highlights', async () => {
searchAndContentsMock.mockResolvedValue({
results: [{ url: 'https://no-title.com' }],
});
const result = await handler({ query: 'edge case' });
expect(result.result).toEqual([
{ title: '', url: 'https://no-title.com', snippet: '' },
]);
});
it('should charge the Exa base price (7000 micro-credits) for up to DEFAULT_NUM_RESULTS results', async () => {
searchAndContentsMock.mockResolvedValue({
results: [{ title: 'A', url: 'https://a.com', highlights: ['x'] }],
});
await handler({ query: 'pricing base' });
expect(chargeCreditsMock).toHaveBeenCalledWith({
creditsUsedMicro: 7000,
operationType: 'WEB_SEARCH',
resourceContext: 'exa',
});
});
it('should add 1000 micro-credits per result beyond DEFAULT_NUM_RESULTS', async () => {
searchAndContentsMock.mockResolvedValue({
results: Array.from({ length: 12 }, (_, index) => ({
title: `Result ${index}`,
url: `https://example.com/${index}`,
highlights: ['snippet'],
})),
});
await handler({ query: 'pricing extra', numResults: 12 });
expect(chargeCreditsMock).toHaveBeenCalledWith(
expect.objectContaining({ creditsUsedMicro: 9000 }),
);
});
it('should return a failure result and not charge credits when the Exa search throws', async () => {
searchAndContentsMock.mockRejectedValue(new Error('Exa rate limit exceeded'));
const result = await handler({ query: 'boom' });
expect(result).toEqual({
success: false,
message: 'Web search failed for "boom"',
error: 'Exa rate limit exceeded',
});
expect(chargeCreditsMock).not.toHaveBeenCalled();
});
it('should time out and fail when Exa does not respond within the inner timeout', async () => {
vi.useFakeTimers();
searchAndContentsMock.mockReturnValue(new Promise<never>(() => {}));
const resultPromise = handler({ query: 'slow query' });
await vi.advanceTimersByTimeAsync(25_000);
const result = await resultPromise;
expect(result).toEqual({
success: false,
message: 'Web search failed for "slow query"',
error: 'Exa search timed out',
});
expect(chargeCreditsMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1 @@
export const DEFAULT_NUM_RESULTS = 10;

View File

@@ -0,0 +1,11 @@
// Mirrors exa-js's `BaseSearchOptions['category']` union. Kept as a
// runtime list so the tool's JSON-Schema `enum` can reference it.
export const EXA_CATEGORIES = [
'company',
'research paper',
'news',
'pdf',
'personal site',
'financial report',
'people',
] as const;

View File

@@ -0,0 +1,118 @@
import Exa from 'exa-js';
import { chargeCredits } from 'twenty-sdk/billing';
import { defineLogicFunction } from 'twenty-sdk/define';
import { DEFAULT_NUM_RESULTS } from './constants/default-num-results.constant';
import { exaWebSearchInputSchema } from './schemas/exa-web-search-input.schema';
import { type ExaWebSearchInput } from './types/exa-web-search-input.type';
// Number of sentences surfaced per result — keeps the snippet compact
// enough for an LLM to read many results without blowing the context.
const HIGHLIGHT_NUM_SENTENCES = 5;
// Inner bound — the runtime's `timeoutSeconds: 30` is the outer kill
// switch; this one ensures we return a clean error on a slow Exa response.
const EXA_SEARCH_TIMEOUT_MS = 25_000;
// Exa auto-search pricing (2025): $0.007 covers the first 10 results,
// $0.001 per additional result. Twenty charges in micro-credits where
// 1 USD = 1_000_000 micro-credits (DOLLAR_TO_CREDIT_MULTIPLIER).
const MICRO_CREDITS_PER_DOLLAR = 1_000_000;
const EXA_BASE_COST_DOLLARS = 0.007;
const EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS = 0.001;
type ExaSearchResult = {
title: string;
url: string;
snippet: string;
};
type HandlerResult = {
success: boolean;
message: string;
result?: ExaSearchResult[];
error?: string;
};
const computeMicroCredits = (numResults: number): number => {
const additional = Math.max(0, numResults - DEFAULT_NUM_RESULTS);
const dollars =
EXA_BASE_COST_DOLLARS + additional * EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS;
return Math.round(dollars * MICRO_CREDITS_PER_DOLLAR);
};
const handler = async (
parameters: ExaWebSearchInput,
): Promise<HandlerResult> => {
const apiKey = process.env.EXA_API_KEY;
if (!apiKey) {
return {
success: false,
message: 'Exa is not configured',
error:
'EXA_API_KEY is not set. The server admin must provide an Exa API key for this tool to work.',
};
}
const query = parameters.query;
const numResults = parameters.numResults ?? DEFAULT_NUM_RESULTS;
const category = parameters.category;
try {
const exa = new Exa(apiKey);
// exa-js has no built-in abort — race it manually.
const response = await Promise.race([
exa.searchAndContents(query, {
type: 'auto',
numResults,
category,
highlights: { numSentences: HIGHLIGHT_NUM_SENTENCES },
}),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Exa search timed out')),
EXA_SEARCH_TIMEOUT_MS,
),
),
]);
const results: ExaSearchResult[] = response.results.map((result) => ({
title: result.title ?? '',
url: result.url,
snippet: result.highlights?.join('\n') ?? '',
}));
await chargeCredits({
creditsUsedMicro: computeMicroCredits(results.length),
operationType: 'WEB_SEARCH',
resourceContext: 'exa',
});
return {
success: true,
message: `Found ${results.length} results for "${query}"${category ? ` (category: ${category})` : ''}`,
result: results,
};
} catch (error) {
return {
success: false,
message: `Web search failed for "${query}"`,
error: error instanceof Error ? error.message : 'Web search failed',
};
}
};
export default defineLogicFunction({
universalIdentifier: '4c6f9b2a-5d8e-4c2a-af18-3e0b9c6a7e4f',
name: 'exa_web_search',
description:
'Structured web search powered by Exa. Returns entity-aware results with category filtering (companies, people, research papers, news, and other content types). Prefer this when the query benefits from structured data or a specific category. For general real-time web browsing, prefer the native `web_search` tool when it is available.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: exaWebSearchInputSchema,
},
handler,
});

View File

@@ -0,0 +1,31 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
import { DEFAULT_NUM_RESULTS } from '../constants/default-num-results.constant';
import { EXA_CATEGORIES } from '../constants/exa-categories.constant';
const MAX_NUM_RESULTS = 30;
export const exaWebSearchInputSchema: InputJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
description:
'The search query to look up on the web. Be specific and include relevant keywords for better results.',
},
category: {
type: 'string',
enum: [...EXA_CATEGORIES],
description:
'Optional content category to focus the search. Use "company" for business/organization info, "people" for person profiles, "news" for recent articles, "research paper" for academic content.',
},
numResults: {
type: 'integer',
minimum: 1,
maximum: MAX_NUM_RESULTS,
description: `Number of search results to return. Defaults to ${DEFAULT_NUM_RESULTS}, max ${MAX_NUM_RESULTS}. Use more results when you need comprehensive coverage.`,
},
},
required: ['query'],
additionalProperties: false,
};

View File

@@ -0,0 +1,7 @@
import { type EXA_CATEGORIES } from '../constants/exa-categories.constant';
export type ExaWebSearchInput = {
query: string;
category?: (typeof EXA_CATEGORIES)[number];
numResults?: number;
};

View File

@@ -0,0 +1,24 @@
import { defineRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'9a55f3d2-f87c-4f1b-a0f3-5d1c6b8a2e4c';
// Exa's logic function never reads workspace data — it only reads
// EXA_API_KEY and calls Exa's external API — so the role needs no object
// permissions. Kept explicit so the manifest records the "zero data
// access" posture.
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Exa function role',
description: 'No-op role for the exa_web_search logic function',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
});

View File

@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,31 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});

View File

@@ -0,0 +1,14 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});

File diff suppressed because it is too large Load Diff