The real problem
Why this is harder than it looks
Exa doesn’t use OAuth. It authenticates with an API key — which sounds simpler, but introduces a different class of problems when you’re building a multi-tenant product where each of your customers connects their own Exa account.
The first issue is key isolation. In a single-user prototype, you put the key in a .env file and move on. In a product with dozens of customers, each with their own Exa subscription and credit balance, you need per-user key storage that’s cryptographically isolated — one customer’s key must never be accessible to another, even within the same codebase. Building that isolation correctly isn’t a line of code; it’s an architectural constraint that touches your database schema, your secrets management layer, and how you pass credentials at request time.
The second issue is tool selection. Exa exposes semantically similar tools that have meaningfully different cost and behavior profiles. exa_search is targeted and cheap. exa_research runs multiple sub-queries in parallel and costs significantly more. exa_websets is designed for large-scale URL discovery and is more expensive still. Requesting text, highlights, or summary on any result costs extra credits per result — omitting those fields returns metadata only and saves credits. An agent that doesn’t distinguish between these tools will silently drain a customer’s credit balance on the wrong call pattern. That’s a support ticket, not a bug.
Finally, there’s the neural vs keyword distinction. Exa’s default neural mode finds conceptually related pages even when your exact query terms don’t appear. That’s powerful for natural language queries, but the wrong choice for precise product names, code identifiers, or quoted strings — where keyword mode is correct. Getting this wrong produces results that look plausible but miss what the user actually needed. Scalekit handles key storage and per-user isolation so your agent only has to name the tool and pass parameters.
Capabilities
What your agent can do with Exa
Once connected, your agent has 10 pre-built tools covering Exa’s full surface area:
- Semantic and keyword web search: query the web with neural AI search or exact-match keyword mode, filter by domain, date range, and content category
- Direct answers with citations: get a synthesized natural language answer to any question with source references, ready to feed into your agent’s context
- Page crawling and content extraction: retrieve full text, highlights, and AI-generated summaries from specific URLs discovered via search
- Similarity search: find pages conceptually similar to a given URL — useful for competitor discovery and related content surfacing
- Deep multi-source research: run parallel sub-queries across many sources and receive structured multi-source output for comprehensive topic analysis
- Large-scale URL discovery with Websets: collect thousands of URLs matching specific criteria for lead generation, market research, and bulk data collection
Setup context
What we’re building
This guide connects a research assistant agent to Exa — letting your users run semantic web searches, pull cited answers, and conduct deep research without leaving your product.
🤖
Example agent
Research assistant running semantic searches and generating cited answers on behalf of each user
🔐
Auth model
API Key — each user connects their own Exa account. identifier = your user ID
Setup
1 Setup: One SDK, One credential
Install the Scalekit SDK. The only credential your application manages is the Scalekit API key — no Exa secrets, no user API keys, nothing belonging to your customers.
pip install scalekit-sdk-python
npm install @scalekit-sdk/node
import scalekit.client
import os
from dotenv import load_dotenv
load_dotenv()
scalekit = scalekit.client.ScalekitClient(
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
env_url=os.getenv("SCALEKIT_ENV_URL"),
)
actions = scalekit.actions
import { ScalekitClient } from '@scalekit-sdk/node';
import 'dotenv/config';
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENV_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET
);
const actions = scalekit.actions;
Already have credentials?
Connected Accounts
2 Per-User Auth: Creating connected accounts
Each user gets their own Connected Account backed by their Exa API key. The identifier is any unique string from your system — a UUID, email, whatever you use internally.
response = actions.get_or_create_connected_account(
connection_name="exa",
identifier="user_exa_123" # your internal user ID
)
connected_account = response.connected_account
print(f"Status: {connected_account.status}")
# Status: PENDING — user hasn't provided their API key yet
const response = await actions.getOrCreateConnectedAccount({
connectionName: "exa",
identifier: "user_exa_123" // your internal user ID
});
const connectedAccount = response.connectedAccount;
console.log(`Status: ${connectedAccount.status}`);
// Status: PENDING — user hasn't provided their API key yet
This call is idempotent — safe to call on every session start. Returns the existing account if one already exists.
Authorization Flow
3 The authorization flow
Because Exa uses API key auth (not OAuth), the user provides their Exa API key once through Scalekit’s credential collection flow. After that, you never see the key.
if connected_account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name="exa",
identifier="user_exa_123"
)
# Redirect user → Scalekit's key collection screen
# Scalekit stores the key encrypted in its vault
return redirect(link.link)
if (connectedAccount.status !== "ACTIVE") {
const { link } = await actions.getAuthorizationLink({
connectionName: "exa",
identifier: "user_exa_123"
});
// Redirect user → Scalekit's key collection screen
// Scalekit stores the key encrypted in its vault
return redirect(link);
}
Key storage is automatic
After the user submits their Exa API key, Scalekit stores it encrypted in its vault and the connected account moves to ACTIVE. The key is injected into every API call automatically — your agent code never touches it. If the key is revoked or invalid, the account moves to REVOKED. Check account.status before critical operations.
Bring Your Own Credentials — required
Exa uses API key authentication. Each user must supply their own Exa API key, obtained from exa.ai. Register the connection in the Scalekit dashboard and Scalekit will handle secure collection, encrypted storage, and per-request injection. No OAuth flow is involved.
Calling Exa
4 Calling Exa: What your agent writes
With the connected account active, your agent calls Exa actions using execute_tool. Name the tool, pass parameters. Scalekit handles key retrieval and request construction.
Semantic web search
Search the web with Exa’s neural AI engine. Use type: "neural" for natural language queries, type: "keyword" for exact product names, code identifiers, or quoted strings. Omit include_text to return metadata only and save credits.
result = actions.execute_tool(
identifier="user_exa_123",
tool_name="exa_search",
tool_input={
"query": "best practices for multi-tenant SaaS token storage",
"type": "neural",
"num_results": 10,
"include_text": True,
"max_characters": 3000,
"start_published_date": "2024-01-01T00:00:00.000Z"
}
)
# Returns: list of results with url, title, published_date, and text per page
const result = await actions.executeTool({
identifier: "user_exa_123",
toolName: "exa_search",
toolInput: {
"query": "best practices for multi-tenant SaaS token storage",
"type": "neural",
"num_results": 10,
"include_text": true,
"max_characters": 3000,
"start_published_date": "2024-01-01T00:00:00.000Z"
}
});
// Returns: list of results with url, title, published_date, and text per page
Get a direct answer with citations
Ask a question in natural language and receive a synthesized answer with source links. Useful for grounding your agent’s responses in current web information without building a retrieval pipeline yourself.
result = actions.execute_tool(
identifier="user_exa_123",
tool_name="exa_answer",
tool_input={
"query": "What are the current rate limits for the Anthropic Claude API?",
"num_results": 5,
"include_text": True
}
)
# Returns: { "answer": "...", "sources": [ { "url": "...", "title": "..." }, ... ] }
const result = await actions.executeTool({
identifier: "user_exa_123",
toolName: "exa_answer",
toolInput: {
"query": "What are the current rate limits for the Anthropic Claude API?",
"num_results": 5,
"include_text": true
}
});
// Returns: { "answer": "...", "sources": [ { "url": "...", "title": "..." }, ... ] }
Crawl a specific page
Retrieve the content of a URL your agent has already identified — from a prior search or a user-provided link. Use include_summary to get an AI-generated summary focused on a specific aspect.
result = actions.execute_tool(
identifier="user_exa_123",
tool_name="exa_crawl",
tool_input={
"urls": ["https://docs.scalekit.com/agent-auth/quickstart/"],
"include_summary": True,
"summary_query": "What auth patterns does this page describe?",
"max_characters": 5000
}
)
# Returns: list of pages with text and AI summary per URL
const result = await actions.executeTool({
identifier: "user_exa_123",
toolName: "exa_crawl",
toolInput: {
"urls": ["https://docs.scalekit.com/agent-auth/quickstart/"],
"include_summary": true,
"summary_query": "What auth patterns does this page describe?",
"max_characters": 5000
}
});
// Returns: list of pages with text and AI summary per URL
Deep multi-source research
Use exa_research when a single search isn’t enough — it runs parallel sub-queries and synthesizes results from multiple sources. Costs significantly more credits than exa_search; use deliberately.
result = actions.execute_tool(
identifier="user_exa_123",
tool_name="exa_research",
tool_input={
"query": "How are AI companies handling multi-tenant auth for agent products in 2025?",
"num_results": 10,
"max_characters": 5000,
"include_domains": ["techcrunch.com", "a16z.com", "martinfowler.com"]
}
)
# Returns: structured multi-source research output with page text and summaries
const result = await actions.executeTool({
identifier: "user_exa_123",
toolName: "exa_research",
toolInput: {
"query": "How are AI companies handling multi-tenant auth for agent products in 2025?",
"num_results": 10,
"max_characters": 5000,
"include_domains": ["techcrunch.com", "a16z.com", "martinfowler.com"]
}
});
// Returns: structured multi-source research output with page text and summaries
Framework wiring
5 Wiring into your agent framework
Scalekit integrates directly with LangChain. The agent decides which search tool to call; Scalekit handles key injection on every invocation. No credential plumbing in your agent logic.
from langchain_anthropic import ChatAnthropic
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from scalekit.langchain import get_tools
exa_tools = get_tools(
connection_name="exa",
identifier="user_exa_123"
)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a research assistant. Use the available tools to search the web, answer questions with citations, and conduct in-depth research on behalf of the user."),
MessagesPlaceholder("chat_history", optional=True),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
agent = create_tool_calling_agent(ChatAnthropic(model="claude-sonnet-4-6"), exa_tools, prompt)
result = AgentExecutor(agent=agent, tools=exa_tools).invoke({
"input": "Research the current landscape of AI agent authentication and summarize the top approaches with sources"
})
import { ChatAnthropic } from "@langchain/anthropic";
import { AgentExecutor, createToolCallingAgent } from "langchain/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { getTools } from "@scalekit-sdk/langchain";
const exaTools = getTools({
connectionName: "exa",
identifier: "user_exa_123"
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a research assistant. Use the available tools to search the web, answer questions with citations, and conduct in-depth research on behalf of the user."],
new MessagesPlaceholder("chat_history", true),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const agent = await createToolCallingAgent({
llm: new ChatAnthropic({ model: "claude-sonnet-4-6" }),
tools: exaTools,
prompt
});
const result = await AgentExecutor.fromAgentAndTools({
agent,
tools: exaTools
}).invoke({
input: "Research the current landscape of AI agent authentication and summarize the top approaches with sources"
});
Other frameworks supported
Tool reference
All 10 Exa tools
Grouped by capability. Your agent calls tools by name — no API wrappers to write.
Search the web with neural AI or exact keyword mode. Filter by domain, date range, and content category. Optionally return page text, highlights, or summaries alongside results
Find web pages conceptually similar to a given URL using neural similarity search. Useful for competitor discovery and related content surfacing
Get a synthesized natural language answer to any question sourced from the web, with citations. Ideal for factual queries and real-time Q&A grounding
Crawl one or more URLs and extract full text, highlights, and AI-generated summaries. Use after search to retrieve full page content from discovered results
Run in-depth research by executing parallel sub-queries across many sources. Returns structured multi-source output with full text and summaries. Higher credit cost than exa_search — use deliberately
Start a large-scale async URL discovery job — collect hundreds to thousands of URLs matching specific criteria. Returns a webset ID to poll. High credit consumption
Poll the status of an existing webset (created, running, completed, cancelled) and retrieve its configuration and progress
Retrieve the discovered URLs and items from a completed webset, with cursor-based pagination
List all websets in the user’s account with pagination. Returns IDs, statuses, and configurations
Permanently delete a webset and all its collected items by ID. This action cannot be undone
Connector notes
Exa-specific behavior
Content fields cost extra credits — omit when not needed
Requesting text, highlights, or summary on any result costs additional credits per result on top of the base search cost. If your agent only needs URLs and titles for a routing step, omit those fields entirely — metadata-only results are free of the per-result content charge. Pass include_text only when your agent will actually consume the page content.
exa_research vs exa_search: choose deliberately
exa_research runs multiple sub-queries in parallel and costs significantly more than exa_search. Use exa_search for targeted, single-intent queries. Reserve exa_research for topics that genuinely require multi-angle synthesis — competitive landscapes, comprehensive literature reviews, and similar tasks where breadth matters more than speed.
Websets are async — poll until completed before reading items
exa_websets returns a webset ID immediately; the actual collection runs asynchronously. Use exa_get_webset to poll status until it returns completed, then call exa_list_webset_items to retrieve results. Attempting to list items before completion will return an empty or partial set.
Neural vs keyword: match mode to query intent
Neural mode (default) finds conceptually related pages even when your exact query terms don’t appear — best for natural language questions. Keyword mode performs exact-match search and is correct for specific product names, error codes, quoted strings, or code identifiers. The auto type lets Exa decide based on query shape, but for agent use cases with predictable query patterns, setting the type explicitly produces more consistent results.
Infrastructure decision
Why not build this yourself
The Exa API is documented. Storing an API key isn’t technically hard. But here’s what you’re actually signing up for:
PROBLEM 01
Per-user API key isolation across a multi-tenant system — one customer’s Exa key must never be accessible to another, even under the same codebase or database
PROBLEM 02
Encrypted key storage and secure injection at request time — not an env variable, not a plaintext database column: a proper secrets management layer with access controls
PROBLEM 03
Key revocation detection — when a user rotates or deletes their Exa API key, your agent must stop making calls immediately and surface a re-connection prompt rather than returning silent 401s
PROBLEM 04
Building a credential collection UI that doesn’t expose the key in transit or in logs — then doing it again for the next API-key-authenticated connector your customers ask for
That’s one connector. Your agent product will eventually need Salesforce, GitHub, Slack, HubSpot, and whatever else your customers ask for. Each has its own auth quirks and failure modes.
Scalekit maintains every connector. You maintain none of them.