> ## Documentation Index
> Fetch the complete documentation index at: https://inference-docs.cerebras.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Grounded Research Agent with Exa

> Use Exa search with Cerebras tool calling to build an agent that searches the web and produces cited answers.

export const CookbookLayout = () => {
  return <div className="full-width-layout" />;
};

export const AuthorBlock = ({name, title, date, githubUrl}) => {
  return <div style={{
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginBottom: '1rem',
    marginTop: '1rem',
    gap: '1rem'
  }}>
      <div>
        <strong>{name}</strong>
        {title && <><br /><span style={{
    fontSize: '0.9em',
    color: '#666'
  }}>{title}</span></>}
        {date && <><br /><span style={{
    fontSize: '0.85em',
    color: '#888'
  }}>{date}</span></>}
      </div>

      {githubUrl && <a href={githubUrl} target="_blank" rel="noopener noreferrer" className="github-button" style={{
    display: 'flex',
    alignItems: 'center',
    gap: '0.5rem',
    padding: '0.5rem 0.75rem',
    textDecoration: 'none',
    borderRadius: '6px',
    fontSize: '0.875rem',
    fontWeight: '500',
    transition: 'all 0.2s'
  }}>
          <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{
    flexShrink: 0
  }}>
            <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
          </svg>
          Open in Github
        </a>}
    </div>;
};

<CookbookLayout />

<AuthorBlock name="Ryan Loney" date="June 22, 2026" />

This cookbook shows how to build a grounded research agent that can:

* Search the web for current information with Exa
* Hand the results to a Cerebras model through tool calling
* Return answers with inline citations and a clean source list

Exa search returns clean page content (highlights) with every result, so a single search tool is enough to ground your AI agent.

## Prerequisites

Before you begin, ensure you have:

* A Cerebras API key
* An Exa API key
* Python 3.10+ or Node.js 18+

Install the dependencies:

<CodeGroup>
  ```bash Python theme={null}
  pip install "exa-py>=2.0" openai python-dotenv
  ```

  ```bash Node.js theme={null}
  npm install exa-js openai dotenv
  ```
</CodeGroup>

<Note>
  The Node.js examples use ES modules and top-level `await`. Save them with a `.mjs` extension (or set `"type": "module"` in your `package.json`) and run them with `node file.mjs`.
</Note>

Then store your API keys in a `.env` file:

```bash theme={null}
CEREBRAS_API_KEY=your-cerebras-api-key
EXA_API_KEY=your-exa-api-key
```

Get your keys here: [Cerebras](https://cloud.cerebras.ai?utm_source=3pi_exa-grounded-research\&utm_campaign=docs) and [Exa](https://dashboard.exa.ai/api-keys).

## Step 1: Initialize the Clients

We use Exa for search and the OpenAI client against Cerebras' OpenAI-compatible API for agent reasoning and tool use.

<CodeGroup>
  ```python Python theme={null}
  import json
  import os
  import re
  from dotenv import load_dotenv
  from exa_py import Exa
  from openai import OpenAI

  load_dotenv()

  exa = Exa(api_key=os.environ["EXA_API_KEY"])
  exa.headers["x-exa-integration"] = "cerebras-integration"

  cerebras = OpenAI(
      api_key=os.environ["CEREBRAS_API_KEY"],
      base_url="https://api.cerebras.ai/v1",
      default_headers={"X-Cerebras-3rd-Party-Integration": "exa"},
  )
  ```

  ```javascript Node.js theme={null}
  import 'dotenv/config';
  import Exa from 'exa-js';
  import OpenAI from 'openai';

  const exa = new Exa(process.env.EXA_API_KEY);
  exa.headers.set('x-exa-integration', 'cerebras-integration');

  const cerebras = new OpenAI({
    apiKey: process.env.CEREBRAS_API_KEY,
    baseURL: 'https://api.cerebras.ai/v1',
    defaultHeaders: { 'X-Cerebras-3rd-Party-Integration': 'exa' },
  });
  ```
</CodeGroup>

## Step 2: Define the Exa Search Tool

The agent gets one tool: `exa_search`. It returns clean highlights for each result, with each source tagged `[n]` so the model can cite it.

A `finalize` helper cleans up the model's output and appends a numbered source list, so every answer ends with reliable citations.

<CodeGroup>
  ```python Python theme={null}
  sources = []
  index_by_url = {}

  def register(title, url):
      if url not in index_by_url:
          sources.append((title or url, url))
          index_by_url[url] = len(sources)
      return index_by_url[url]

  def exa_search(query, type="auto", num_results=10, max_age_hours=None, **_):
      contents = {"highlights": True}
      if max_age_hours is not None:
          contents["max_age_hours"] = max_age_hours
      results = exa.search(query, type=type, num_results=num_results, contents=contents)
      return "\n\n".join(
          f"[{register(r.title, r.url)}] {r.title or r.url}\nURL: {r.url}\n{' '.join(r.highlights or [])}"
          for r in results.results
      )

  # Remove stray citation markers (e.g. 【†L1-L9】) the model sometimes adds.
  GARBAGE = re.compile(r"【[^】]*】|\d*†[^\s\]】]*】?|[【】†]")

  def finalize(answer):
      answer = GARBAGE.sub("", answer)
      answer = re.sub(r"\[\[(\d+)\]\]", r"[\1]", answer).strip()
      if not sources:
          return answer
      lines = "\n".join(f"[{i}] {title} - {url}" for i, (title, url) in enumerate(sources, 1))
      return f"{answer}\n\nSources:\n{lines}"
  ```

  ```javascript Node.js theme={null}
  const sources = [];
  const indexByUrl = new Map();

  function register(title, url) {
    if (!indexByUrl.has(url)) {
      sources.push({ title: title || url, url });
      indexByUrl.set(url, sources.length);
    }
    return indexByUrl.get(url);
  }

  async function exaSearch({ query, type = 'auto', numResults = 10, maxAgeHours }) {
    const contents = { highlights: true };
    if (maxAgeHours !== undefined) contents.maxAgeHours = maxAgeHours;
    const results = await exa.search(query, { type, numResults, contents });
    return results.results
      .map(
        (r) =>
          `[${register(r.title, r.url)}] ${r.title || r.url}\nURL: ${r.url}\n${(r.highlights || []).join(' ')}`
      )
      .join('\n\n');
  }

  function finalize(answer) {
    // Remove stray citation markers (e.g. 【†L1-L9】) the model sometimes adds.
    let text = (answer || '').replace(/【[^】]*】|\d*†[^\s\]】]*】?|[【】†]/g, '');
    text = text.replace(/\[\[(\d+)\]\]/g, '[$1]').trim();
    if (sources.length === 0) return text;
    const lines = sources.map((s, i) => `[${i + 1}] ${s.title} - ${s.url}`).join('\n');
    return `${text}\n\nSources:\n${lines}`;
  }
  ```
</CodeGroup>

## Step 3: Register the Tool for the Model

The schema exposes the three search types so the model can choose faster or deeper search per query. Only `query` is required; everything else is optional.

<CodeGroup>
  ```python Python theme={null}
  tools = [
      {
          "type": "function",
          "function": {
              "name": "exa_search",
              "description": "Search the web with Exa and get clean, ready-to-use results. Best for current information, news, facts, people, and companies. Returns numbered sources [n] with title, URL, and highlights.",
              "parameters": {
                  "type": "object",
                  "properties": {
                      "query": {"type": "string", "description": "The search query."},
                      "type": {
                          "type": "string",
                          "enum": ["auto", "fast", "deep"],
                          "description": "Search strategy. 'auto' (default and recommended) balances quality and speed; 'fast' is the lowest-latency option; 'deep' is most thorough.",
                      },
                      "num_results": {
                          "type": "integer",
                          "description": "Number of results to return (1-100, default 10).",
                      },
                      "max_age_hours": {
                          "type": "integer",
                          "description": "Only accept cached pages newer than this many hours; older pages are refreshed before returning. Omit for no freshness limit, 0 to always fetch fresh content, or -1 to use cached content only.",
                      },
                  },
                  "required": ["query"],
              },
          },
      }
  ]

  available_tools = {"exa_search": exa_search}
  ```

  ```javascript Node.js theme={null}
  const tools = [
    {
      type: 'function',
      function: {
        name: 'exa_search',
        description:
          'Search the web with Exa and get clean, ready-to-use results. Best for current information, news, facts, people, and companies. Returns numbered sources [n] with title, URL, and highlights.',
        parameters: {
          type: 'object',
          properties: {
            query: { type: 'string', description: 'The search query.' },
            type: {
              type: 'string',
              enum: ['auto', 'fast', 'deep'],
              description:
                "Search strategy. 'auto' (default and recommended) balances quality and speed; 'fast' is the lowest-latency option; 'deep' is most thorough.",
            },
            numResults: {
              type: 'integer',
              description: 'Number of results to return (1-100, default 10).',
            },
            maxAgeHours: {
              type: 'integer',
              description:
                'Only accept cached pages newer than this many hours; older pages are refreshed before returning. Omit for no freshness limit, 0 to always fetch fresh content, or -1 to use cached content only.',
            },
          },
          required: ['query'],
        },
      },
    },
  ];

  const availableTools = { exa_search: exaSearch };
  ```
</CodeGroup>

## Step 4: Run the Agent Loop

The core pattern is:

1. Ask the model what it needs
2. Let it call the search tool
3. Feed tool results back into the conversation
4. Stop when the model returns a final answer

The loop has a step limit, calls tools safely, and passes any tool error back to the model as a tool message so it can fix its input instead of crashing.

<CodeGroup>
  ```python Python theme={null}
  def run_research_agent(question):
      messages = [
          {
              "role": "system",
              "content": (
                  "You are a research analyst. Use exa_search to find current sources, then answer "
                  "the question. Cite sources inline as [n], matching the labels returned by "
                  "exa_search (for example [1] or [2])."
              ),
          },
          {"role": "user", "content": question},
      ]

      for _ in range(6):
          response = cerebras.chat.completions.create(
              model="gpt-oss-120b",
              messages=messages,
              tools=tools,
              tool_choice="auto",
              max_completion_tokens=2000,
          )
          message = response.choices[0].message
          messages.append(message)

          if not message.tool_calls:
              return finalize(message.content or "")

          for tool_call in message.tool_calls:
              tool_fn = available_tools.get(tool_call.function.name)
              try:
                  args = json.loads(tool_call.function.arguments)
                  result = tool_fn(**args) if tool_fn else f"Unknown tool: {tool_call.function.name}"
              except Exception as e:
                  result = f"Tool error ({type(e).__name__}): {e}. Adjust your arguments and try again."
              messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})

      return "Could not produce a final answer within the step limit."
  ```

  ```javascript Node.js theme={null}
  async function runResearchAgent(question) {
    const messages = [
      {
        role: 'system',
        content:
          'You are a research analyst. Use exa_search to find current sources, then answer the question. ' +
          'Cite sources inline as [n], matching the labels returned by exa_search (for example [1] or [2]).',
      },
      { role: 'user', content: question },
    ];

    for (let step = 0; step < 6; step++) {
      const response = await cerebras.chat.completions.create({
        model: 'gpt-oss-120b',
        messages,
        tools,
        tool_choice: 'auto',
        max_completion_tokens: 2000,
      });

      const message = response.choices[0].message;
      messages.push(message);

      if (!message.tool_calls) {
        return finalize(message.content || '');
      }

      for (const toolCall of message.tool_calls) {
        const toolFn = availableTools[toolCall.function.name];
        let result;
        try {
          const args = JSON.parse(toolCall.function.arguments);
          result = toolFn ? await toolFn(args) : `Unknown tool: ${toolCall.function.name}`;
        } catch (err) {
          result = `Tool error (${err.name}): ${err.message}. Adjust your arguments and try again.`;
        }
        messages.push({ role: 'tool', tool_call_id: toolCall.id, content: result });
      }
    }

    return 'Could not produce a final answer within the step limit.';
  }
  ```
</CodeGroup>

## Step 5: Try It on a Real Question

Now you can ask for a grounded answer. The agent searches the web, then writes a cited answer.

<CodeGroup>
  ```python Python theme={null}
  question = "How are AI agents being used in production today? Cite specific examples."
  answer = run_research_agent(question)
  print(answer)
  ```

  ```javascript Node.js theme={null}
  const question = 'How are AI agents being used in production today? Cite specific examples.';
  const answer = await runResearchAgent(question);
  console.log(answer);
  ```
</CodeGroup>

<Note>
  Inline `[n]` markers map to the numbered **Sources** list at the end of the answer. Non-consecutive citations like `[1]`, `[2]`, and `[4]` are expected when the model cites only some of the results.
</Note>

## Complete Example

The full agent in a single file. Copy it into `agent.py` (or `agent.mjs`) and run it.

<CodeGroup>
  ```python Python theme={null}
  import json
  import os
  import re
  from dotenv import load_dotenv
  from exa_py import Exa
  from openai import OpenAI

  load_dotenv()

  exa = Exa(api_key=os.environ["EXA_API_KEY"])
  exa.headers["x-exa-integration"] = "cerebras-integration"

  cerebras = OpenAI(
      api_key=os.environ["CEREBRAS_API_KEY"],
      base_url="https://api.cerebras.ai/v1",
      default_headers={"X-Cerebras-3rd-Party-Integration": "exa"},
  )


  sources = []
  index_by_url = {}

  def register(title, url):
      if url not in index_by_url:
          sources.append((title or url, url))
          index_by_url[url] = len(sources)
      return index_by_url[url]

  def exa_search(query, type="auto", num_results=10, max_age_hours=None, **_):
      contents = {"highlights": True}
      if max_age_hours is not None:
          contents["max_age_hours"] = max_age_hours
      results = exa.search(query, type=type, num_results=num_results, contents=contents)
      return "\n\n".join(
          f"[{register(r.title, r.url)}] {r.title or r.url}\nURL: {r.url}\n{' '.join(r.highlights or [])}"
          for r in results.results
      )

  # Remove stray citation markers (e.g. 【†L1-L9】) the model sometimes adds.
  GARBAGE = re.compile(r"【[^】]*】|\d*†[^\s\]】]*】?|[【】†]")

  def finalize(answer):
      answer = GARBAGE.sub("", answer)
      answer = re.sub(r"\[\[(\d+)\]\]", r"[\1]", answer).strip()
      if not sources:
          return answer
      lines = "\n".join(f"[{i}] {title} - {url}" for i, (title, url) in enumerate(sources, 1))
      return f"{answer}\n\nSources:\n{lines}"


  tools = [
      {
          "type": "function",
          "function": {
              "name": "exa_search",
              "description": "Search the web with Exa and get clean, ready-to-use results. Best for current information, news, facts, people, and companies. Returns numbered sources [n] with title, URL, and highlights.",
              "parameters": {
                  "type": "object",
                  "properties": {
                      "query": {"type": "string", "description": "The search query."},
                      "type": {
                          "type": "string",
                          "enum": ["auto", "fast", "deep"],
                          "description": "Search strategy. 'auto' (default and recommended) balances quality and speed; 'fast' is the lowest-latency option; 'deep' is most thorough.",
                      },
                      "num_results": {
                          "type": "integer",
                          "description": "Number of results to return (1-100, default 10).",
                      },
                      "max_age_hours": {
                          "type": "integer",
                          "description": "Only accept cached pages newer than this many hours; older pages are refreshed before returning. Omit for no freshness limit, 0 to always fetch fresh content, or -1 to use cached content only.",
                      },
                  },
                  "required": ["query"],
              },
          },
      }
  ]

  available_tools = {"exa_search": exa_search}


  def run_research_agent(question):
      messages = [
          {
              "role": "system",
              "content": (
                  "You are a research analyst. Use exa_search to find current sources, then answer "
                  "the question. Cite sources inline as [n], matching the labels returned by "
                  "exa_search (for example [1] or [2])."
              ),
          },
          {"role": "user", "content": question},
      ]

      for _ in range(6):
          response = cerebras.chat.completions.create(
              model="gpt-oss-120b",
              messages=messages,
              tools=tools,
              tool_choice="auto",
              max_completion_tokens=2000,
          )
          message = response.choices[0].message
          messages.append(message)

          if not message.tool_calls:
              return finalize(message.content or "")

          for tool_call in message.tool_calls:
              tool_fn = available_tools.get(tool_call.function.name)
              try:
                  args = json.loads(tool_call.function.arguments)
                  result = tool_fn(**args) if tool_fn else f"Unknown tool: {tool_call.function.name}"
              except Exception as e:
                  result = f"Tool error ({type(e).__name__}): {e}. Adjust your arguments and try again."
              messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})

      return "Could not produce a final answer within the step limit."


  question = "How are AI agents being used in production today? Cite specific examples."
  answer = run_research_agent(question)
  print(answer)
  ```

  ```javascript Node.js theme={null}
  import 'dotenv/config';
  import Exa from 'exa-js';
  import OpenAI from 'openai';

  const exa = new Exa(process.env.EXA_API_KEY);
  exa.headers.set('x-exa-integration', 'cerebras-integration');

  const cerebras = new OpenAI({
    apiKey: process.env.CEREBRAS_API_KEY,
    baseURL: 'https://api.cerebras.ai/v1',
    defaultHeaders: { 'X-Cerebras-3rd-Party-Integration': 'exa' },
  });


  const sources = [];
  const indexByUrl = new Map();

  function register(title, url) {
    if (!indexByUrl.has(url)) {
      sources.push({ title: title || url, url });
      indexByUrl.set(url, sources.length);
    }
    return indexByUrl.get(url);
  }

  async function exaSearch({ query, type = 'auto', numResults = 10, maxAgeHours }) {
    const contents = { highlights: true };
    if (maxAgeHours !== undefined) contents.maxAgeHours = maxAgeHours;
    const results = await exa.search(query, { type, numResults, contents });
    return results.results
      .map(
        (r) =>
          `[${register(r.title, r.url)}] ${r.title || r.url}\nURL: ${r.url}\n${(r.highlights || []).join(' ')}`
      )
      .join('\n\n');
  }

  function finalize(answer) {
    // Remove stray citation markers (e.g. 【†L1-L9】) the model sometimes adds.
    let text = (answer || '').replace(/【[^】]*】|\d*†[^\s\]】]*】?|[【】†]/g, '');
    text = text.replace(/\[\[(\d+)\]\]/g, '[$1]').trim();
    if (sources.length === 0) return text;
    const lines = sources.map((s, i) => `[${i + 1}] ${s.title} - ${s.url}`).join('\n');
    return `${text}\n\nSources:\n${lines}`;
  }


  const tools = [
    {
      type: 'function',
      function: {
        name: 'exa_search',
        description:
          'Search the web with Exa and get clean, ready-to-use results. Best for current information, news, facts, people, and companies. Returns numbered sources [n] with title, URL, and highlights.',
        parameters: {
          type: 'object',
          properties: {
            query: { type: 'string', description: 'The search query.' },
            type: {
              type: 'string',
              enum: ['auto', 'fast', 'deep'],
              description:
                "Search strategy. 'auto' (default and recommended) balances quality and speed; 'fast' is the lowest-latency option; 'deep' is most thorough.",
            },
            numResults: {
              type: 'integer',
              description: 'Number of results to return (1-100, default 10).',
            },
            maxAgeHours: {
              type: 'integer',
              description:
                'Only accept cached pages newer than this many hours; older pages are refreshed before returning. Omit for no freshness limit, 0 to always fetch fresh content, or -1 to use cached content only.',
            },
          },
          required: ['query'],
        },
      },
    },
  ];

  const availableTools = { exa_search: exaSearch };


  async function runResearchAgent(question) {
    const messages = [
      {
        role: 'system',
        content:
          'You are a research analyst. Use exa_search to find current sources, then answer the question. ' +
          'Cite sources inline as [n], matching the labels returned by exa_search (for example [1] or [2]).',
      },
      { role: 'user', content: question },
    ];

    for (let step = 0; step < 6; step++) {
      const response = await cerebras.chat.completions.create({
        model: 'gpt-oss-120b',
        messages,
        tools,
        tool_choice: 'auto',
        max_completion_tokens: 2000,
      });

      const message = response.choices[0].message;
      messages.push(message);

      if (!message.tool_calls) {
        return finalize(message.content || '');
      }

      for (const toolCall of message.tool_calls) {
        const toolFn = availableTools[toolCall.function.name];
        let result;
        try {
          const args = JSON.parse(toolCall.function.arguments);
          result = toolFn ? await toolFn(args) : `Unknown tool: ${toolCall.function.name}`;
        } catch (err) {
          result = `Tool error (${err.name}): ${err.message}. Adjust your arguments and try again.`;
        }
        messages.push({ role: 'tool', tool_call_id: toolCall.id, content: result });
      }
    }

    return 'Could not produce a final answer within the step limit.';
  }


  const question = 'How are AI agents being used in production today? Cite specific examples.';
  const answer = await runResearchAgent(question);
  console.log(answer);
  ```
</CodeGroup>

## Summary

### What We Built

A grounded research agent with:

* Exa search for current source discovery, with page content (highlights) returned inline
* Cerebras tool calling to plan searches and write cited answers
* Reliable inline citations backed by a numbered source list

### Next Steps

* Use `fast` for low-latency chat assistants and `deep` for broader research tasks
* Lower `max_age_hours` for newsy queries that need fresher content
* Try other Exa API config in [Exa API Dashboard](https://dashboard.exa.ai)

### Resources

* [Cerebras Inference Docs](https://inference-docs.cerebras.ai)
* [Exa API Dashboard](https://dashboard.exa.ai)
* [Get Started with Exa](/integrations/exa)
* [Build Your Own Perplexity with Exa](/cookbook/agents/build-your-own-perplexity)
* [Automating Search-Based Report Generation with a Multi-Agent AI Pipeline](/cookbook/agents/search-agent)

### Acknowledgements

Thank you to Ishan Goswami from Exa for his collaboration and feedback during the development of this cookbook.
