Skip to content
unlob

OpenAI · Python and TypeScript

Build a research agent with OpenAI Agents SDK

Build a research agent on the OpenAI Agents SDK, exposing unlob search and context assembly as function tools with handoffs and tracing.

What you are building

Your process

Runner.run

Agent loop with tracing

Agent

Instructions plus tools

Function tools

web_search

Schema from type hints

assemble_context

Schema from type hints

Services

OpenAI API

Model of your choice

unlob

Your own index

Your side of the API. unlob is one HTTPS service among the others — nothing here is specific to how our index works, so this agent survives a change of framework.

Install

Dependenciesbash
pip install openai-agents httpx

Already have OpenAI Agents SDK wired up? TheOpenAI Agents SDK integration pagehas the connection config on its own — this page assumes you have it and gets on with building.

Step by step

  1. Declare tools with @function_tool

    The decorator reads your type hints and docstring to build the schema, so an accurate signature is the whole job. Pydantic types work as parameters if you want stricter validation.

    tools.pypython
    import os, httpx
    from agents import function_tool
    
    API = "https://api.unlob.com"
    HEADERS = {"x-api-key": os.environ["UNLOB_API_KEY"]}
    
    
    @function_tool
    def web_search(query: str, min_sources: int = 2) -> list[dict]:
        """Search the web. Returns metadata only — never page bodies.
    
        Args:
            query: What to search for.
            min_sources: Require this many independent sources. Use 3 for facts.
        """
        r = httpx.get(f"{API}/search", headers=HEADERS, params={
            "q": query, "min_independent_sources": min_sources,
            "fields[]": ["url", "title", "snippet", "independent_sources"],
            "limit": 8,
        }, timeout=15)
        r.raise_for_status()
        return r.json()["results"]
  2. Define the agent and run it

    Runner.run drives the loop. Everything it does is captured in a trace you can open afterwards, which is the feature that makes this SDK pleasant to debug.

    agent.pypython
    from agents import Agent, Runner
    
    agent = Agent(
        name="Researcher",
        instructions=(
            "Search before you answer. Prefer claims carried by several "
            "independent sources, and say so when a claim rests on one. Cite URLs."
        ),
        tools=[web_search, assemble_context],
    )
    
    result = await Runner.run(agent, "What changed in the EU AI Act in 2026?")
    print(result.final_output)
  3. Make failures survivable

    A tool that raises ends the run. Catching the error inside the tool and returning it as a string gives the model something to work with — it can retry with a different query, or tell the user what went wrong, instead of the whole agent dying on a transient 503.

    tools.py — the resilient versionpython
    @function_tool
    def web_search(query: str, min_sources: int = 2) -> list[dict] | str:
        """Search the web. Returns metadata only — never page bodies.
    
        Args:
            query: What to search for.
            min_sources: Require this many independent sources. Use 3 for facts.
        """
        try:
            r = httpx.get(f"{API}/search", headers=HEADERS, params={
                "q": query, "min_independent_sources": min_sources,
                "fields[]": ["url", "title", "snippet", "independent_sources"],
                "limit": 8,
            }, timeout=15)
            r.raise_for_status()
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 429:
                return "Rate limited. Wait a moment, then try a narrower query."
            return f"Search failed with {e.response.status_code}."
        except httpx.RequestError as e:
            return f"Search unreachable: {e!r}"
    
        results = r.json()["results"]
        if not results:
            # An empty result is information, not an error. Say so, or the model
            # will assume the tool is broken and keep calling it.
            return f"No results for {query!r} at min_sources={min_sources}. Try lowering it."
        return results

What happens when it runs

YouRunnerModelunlobRunner.run(agent, q)instructions + toolsfunction_callGET /assemble_contextpacked passagesfunction_call_outputfinal_outputresult + trace
One turn of the loop. Search returns metadata only, so the agent spends a few hundred tokens deciding rather than tens of thousands reading.

The whole thing

Complete and runnable. Set the two environment variables and it works.

research_agent.py — completepython
"""A research agent on the OpenAI Agents SDK, backed by the unlob web index.

    pip install openai-agents httpx
    export UNLOB_API_KEY=ulb_...
    export OPENAI_API_KEY=sk-...
"""
import asyncio
import os

import httpx
from agents import Agent, Runner, function_tool

API = "https://api.unlob.com"
HEADERS = {"x-api-key": os.environ["UNLOB_API_KEY"]}


@function_tool
def web_search(query: str, min_sources: int = 2) -> list[dict]:
    """Search the web. Returns metadata only — never page bodies.

    Args:
        query: What to search for.
        min_sources: Require this many independent sources. Use 3 for facts.
    """
    r = httpx.get(f"{API}/search", headers=HEADERS, params={
        "q": query, "min_independent_sources": min_sources,
        "fields[]": ["url", "title", "snippet", "independent_sources"],
        "limit": 8,
    }, timeout=15)
    r.raise_for_status()
    return r.json()["results"]


@function_tool
def assemble_context(query: str, budget: int = 3000) -> dict:
    """Build a corroborated context pack packed to a token budget. Prefer this
    over several searches when you need to read source material.

    Args:
        query: The question the pack should answer.
        budget: Maximum tokens of context to return.
    """
    r = httpx.get(f"{API}/assemble_context", headers=HEADERS, params={
        "query": query, "budget": budget, "min_independent_sources": 2,
    }, timeout=30)
    r.raise_for_status()
    return r.json()


agent = Agent(
    name="Researcher",
    instructions=(
        "Search before you answer. Prefer claims carried by several "
        "independent sources, and say so when a claim rests on one. Cite URLs."
    ),
    tools=[web_search, assemble_context],
)


async def main() -> None:
    result = await Runner.run(
        agent, "What changed in the EU AI Act GPAI rules in 2026?"
    )
    print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

What will go wrong

The failures that cost an afternoon rather than a minute, because they produce something that looks like it is working.

Where this agent can go next

Every result the agent receives is a node in the coverage graph. These are the tools you can add to it without leaving OpenAI Agents SDK — each is one more entry in the same tools list.

relatedNear-duplicatescorroborateIndependent sourcesauthoritiesAuthoritative hostsdossierThe entity briefpathA second documentassemble_contextA packed context setA search resultwhat your agent has
The two marked in blue are the tools this tutorial wires up. The other four are the same shape.

Frequently asked questions

Can I use Claude models with this SDK?

The SDK is built around the OpenAI API surface. For Anthropic models, LangGraph or Pydantic AI give you provider choice as a first-class feature, and the Claude Agent SDK is the native option.

What are handoffs for?

Delegating to a specialised agent — a research agent handing a numerical question to an analysis agent. They matter once you have more tools than one agent should reasonably choose between, which is usually somewhere past six.

You need a key to run this

10,000 requests a month on the free tier, no card. Enough to build the agent and put a real evaluation set through it.