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

# Research assistant

> Answer questions with live web search, then return a grounded answer with citations using structured output.

This assistant searches the live web with Opper's built-in web tool, then uses [structured output](/build/gateway/structured-output) to write a grounded answer that cites its sources. Two steps: search, then one structured call through the gateway.

<div className="opp-term" style={{ borderRadius: "12px", overflow: "hidden", border: "1px solid #232a33", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "13.5px", lineHeight: "1.65", boxShadow: "0 8px 30px rgba(0,0,0,0.35)", margin: "1.5em 0" }}>
  <div className="opp-head" style={{ display: "flex", alignItems: "center", gap: "7px", padding: "11px 14px", background: "#11161c", borderBottom: "1px solid #232a33" }}>
    <span style={{ width: "11px", height: "11px", borderRadius: "50%", background: "#ff5f56", display: "inline-block" }} />

    <span style={{ width: "11px", height: "11px", borderRadius: "50%", background: "#ffbd2e", display: "inline-block" }} />

    <span style={{ width: "11px", height: "11px", borderRadius: "50%", background: "#27c93f", display: "inline-block" }} />

    <span style={{ marginLeft: "10px", color: "#6e7681", fontSize: "12px" }}>python app.py</span>
  </div>

  <div className="opp-body" style={{ padding: "16px 18px", background: "#0b0f14", color: "#c9d1d9" }}>
    <p data-role="user" style={{ margin: "0 0 16px" }}><span style={{ color: "#58a6ff", fontWeight: 600 }}>You ›</span> What is Mistral's largest open-weight model?</p>
    <p data-role="tool" style={{ margin: "0 0 16px" }}><span style={{ color: "#e3b341", fontWeight: 600 }}>Tool ›</span> web\_search("Mistral largest open-weight model")<br /><span style={{ color: "#6e7681" }}>→ 5 results from the web</span></p>
    <p data-role="bot" style={{ margin: "0 0 16px" }}><span style={{ color: "#14cdcd", fontWeight: 600 }}>Bot ›</span> Mistral's largest open-weight model is Mistral Medium 3.5, a 128B-parameter model for reasoning, coding, and instruction-following.</p>
    <p data-role="result" style={{ margin: 0 }}><span style={{ color: "#e3b341", fontWeight: 600 }}>→</span> <span style={{ color: "#6e7681" }}>sources: \["mindstudio.ai/blog/what-is-mistral-medium-3-5..."]</span><span className="opp-cursor" /></p>
  </div>
</div>

Search results go in, a typed answer with sources comes out.

## The assistant

<CodeGroup>
  ```python app.py theme={null}
  import os, json, requests
  from openai import OpenAI

  KEY = os.environ["OPPER_API_KEY"]
  client = OpenAI(base_url="https://api.opper.ai/v3/compat", api_key=KEY)

  def web_search(query: str) -> list[dict]:
      r = requests.post(
          "https://api.opper.ai/v3/tools/web/search",
          headers={"Authorization": f"Bearer {KEY}"},
          json={"query": query},
      )
      r.raise_for_status()
      return r.json()["results"]

  ANSWER_SCHEMA = {
      "type": "object",
      "properties": {
          "answer": {"type": "string", "description": "A concise answer grounded in the search results."},
          "sources": {"type": "array", "items": {"type": "string"}, "description": "URLs of the results you actually used."},
      },
      "required": ["answer", "sources"],
  }

  def research(question: str):
      results = web_search(question)
      r = client.chat.completions.create(
          model="openai/gpt-5-mini",
          messages=[
              {"role": "system", "content": "Answer the question using only the provided search results. Cite the URLs you used in sources."},
              {"role": "user", "content": f"Question: {question}\n\nResults:\n{json.dumps(results)}"},
          ],
          response_format={"type": "json_schema", "json_schema": {"name": "answer", "schema": ANSWER_SCHEMA}},
      )
      return json.loads(r.choices[0].message.content)

  if __name__ == "__main__":
      data = research("What is Mistral's largest open-weight model?")
      print(data["answer"])
      print("Sources:", data["sources"])
  ```

  ```typescript app.ts theme={null}
  import OpenAI from "openai";

  const KEY = process.env.OPPER_API_KEY!;
  const client = new OpenAI({ baseURL: "https://api.opper.ai/v3/compat", apiKey: KEY });

  async function webSearch(query: string) {
    const r = await fetch("https://api.opper.ai/v3/tools/web/search", {
      method: "POST",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ query }),
    });
    if (!r.ok) throw new Error(`search failed: ${r.status}`);
    return (await r.json()).results;
  }

  async function research(question: string) {
    const results = await webSearch(question);
    const r = await client.chat.completions.create({
      model: "openai/gpt-5-mini",
      messages: [
        { role: "system", content: "Answer the question using only the provided search results. Cite the URLs you used in sources." },
        { role: "user", content: `Question: ${question}\n\nResults:\n${JSON.stringify(results)}` },
      ],
      response_format: {
        type: "json_schema",
        json_schema: {
          name: "answer",
          schema: {
            type: "object",
            properties: { answer: { type: "string" }, sources: { type: "array", items: { type: "string" } } },
            required: ["answer", "sources"],
          },
        },
      },
    });
    return JSON.parse(r.choices[0].message.content!);
  }

  const data = await research("What is Mistral's largest open-weight model?");
  console.log(data.answer);
  console.log("Sources:", data.sources);
  ```
</CodeGroup>

Run it:

<CodeGroup>
  ```bash Python theme={null}
  pip install openai requests
  export OPPER_API_KEY="your-api-key"
  python app.py
  ```

  ```bash TypeScript theme={null}
  npm install openai
  export OPPER_API_KEY="your-api-key"
  npx tsx app.ts
  ```
</CodeGroup>

## How it works

* **Search.** `web_search` calls Opper's hosted web tool (`POST /v3/tools/web/search`) and returns a list of `{title, url, snippet}` results. No separate search API key to manage.
* **Answer.** The [structured output](/build/gateway/structured-output) call gets the question plus those results and returns a typed answer. Because the output is schema-constrained, `answer` and `sources` come back clean after a single parse.
* **Grounding.** The model only sees the results you pass in, so it answers from the search rather than from memory. If a fact isn't in the results, it can say so.

<Tip>
  Pass more or fewer results to trade cost for coverage, and add an [Observe](/control-plane/observe) rule that checks every answer actually cites a source.
</Tip>

## What's next

<CardGroup cols={2}>
  <Card title="Structured output" icon="braces" href="/build/gateway/structured-output">
    The schema-constrained call behind the answer step.
  </Card>

  <Card title="Web search" icon="globe" href="/build/gateway/web-search">
    The portable web tool, across every model.
  </Card>

  <Card title="Tools" icon="screwdriver-wrench" href="/build/gateway/tools">
    Let the model call your code directly.
  </Card>

  <Card title="Observe" icon="eye" href="/control-plane/observe">
    Score answers for grounding and citation quality.
  </Card>
</CardGroup>
