Skip to the page

Mistral Agents API Research

Give a Mistral agent a research tool. You declare research_report as a function tool in the Agents API, beside Mistral's own web_search. When the model calls it, your code starts a SpecStream research report and returns the written answer from the live web, with a numbered list of the pages it cites and when each was read. A report has one fixed price: $0.10, $0.30 or $1.00 by depth.

SpecStream for Mistral Agents API ResearchAgent frameworks & builders

What you need

  • A Mistral API key, and Python with the Mistral SDK and requests (pip install mistralai requests). The code uses the SDK's V2 imports, from mistralai.client import .... On V1 of the SDK, import the same names from mistralai.
  • A SpecStream key. Get your key on the Search API screen of your SpecStream workspace. New accounts start with $10 of credit, no card.
  1. 1. Set both keys

    Create a key in your SpecStream workspace, then set it and your Mistral key on the machine that runs your code. Neither key goes into a file.

    Terminal
    pip install mistralai requests
    export MISTRAL_API_KEY="your Mistral key"
    export SPECSTREAM_API_KEY="your SpecStream key"
  2. 2. Write the research function

    Save this as specstream_research.py. research_report takes the question and a depth: quick, standard or deep. It starts the report with POST /v1/research, then reads GET /v1/research/{id} every 5 seconds for up to 7 minutes. A quick report takes about a minute, a standard report about four and a deep report about five, so the report usually comes back in the same call: the text with [1] markers, then a Sources list with each cited page's title, URL and read time.

    If the report needs longer, the function returns the report ID, which starts with rr_. read_research_report reads a report by that ID and costs nothing.

    Mistral runs a function tool in your environment, so the SpecStream key stays in your process and never reaches the model.

    specstream_research.py
    import hashlib
    import os
    import time
    import requests
    API = "https://api.specstream.ai/v1/research"
    ENDED = ("completed", "failed", "cancelled", "payment_required")
    WAIT_SECONDS = 420 # how long one call waits for the report before it returns the report ID
    def call(method, url, **kwargs):
    headers = {"Authorization": "Bearer " + os.environ["SPECSTREAM_API_KEY"]}
    headers.update(kwargs.pop("headers", {}))
    reply = requests.request(method, url, headers=headers, timeout=60, **kwargs)
    if reply.status_code >= 400:
    raise RuntimeError("SpecStream answered " + str(reply.status_code) + ": " + reply.text[:300])
    return reply.json()["data"]
    def shown(run):
    status = run.get("status")
    if status == "completed":
    report = run.get("report") or {}
    sources = [
    "[" + str(source["n"]) + "] " + source["title"] + " " + source["url"] + " (read " + source["read_at"] + ")"
    for source in report.get("sources") or []
    if source.get("cited")
    ]
    return str(report.get("content") or "") + "\n\nSources\n" + "\n".join(sources)
    if status == "payment_required":
    return (
    "The report is written, but the balance cannot pay for it. Add balance in the SpecStream "
    "workspace, then call read_research_report with report ID " + str(run.get("id")) + "."
    )
    if status in ENDED:
    return "The report did not finish and costs nothing: " + str(status)
    return (
    "The report is still in work (stage: " + str(run.get("stage")) + "). Call read_research_report "
    "with report ID " + str(run.get("id")) + " in a minute."
    )
    def research_report(question, depth="quick"):
    question = (question or "").strip()
    depth = (depth or "quick").strip().lower()
    if not question:
    return "Give the question the report answers."
    if depth not in ("quick", "standard", "deep"):
    return "Set depth to quick, standard or deep."
    # The same question at the same depth in the same hour gets the report it already started,
    # so a repeated tool call never buys the report twice.
    hour = time.strftime("%Y-%m-%dT%H", time.gmtime())
    once = "mistral-" + hashlib.sha256((question + "|" + depth + "|" + hour).encode()).hexdigest()[:32]
    try:
    run = call("POST", API, json={"input": question, "depth": depth}, headers={"Idempotency-Key": once})
    deadline = time.monotonic() + WAIT_SECONDS
    while run.get("status") not in ENDED and time.monotonic() < deadline:
    time.sleep(5)
    run = call("GET", API + "/" + run["id"])
    except RuntimeError as refused:
    return str(refused)
    return shown(run)
    def read_research_report(report_id):
    try:
    return shown(call("GET", API + "/" + (report_id or "").strip()))
    except RuntimeError as refused:
    return str(refused)
  3. 3. Create the agent with the function tool

    Save this as agent.py and run it once. client.beta.agents.create takes the tools list: {"type": "web_search"} is Mistral's built in search, and each {"type": "function", ...} entry is a JSON schema for one of your functions. The description carries the three prices, and the instructions make the agent name the price and wait for a yes before it starts a report.

    The script prints the agent ID, which starts with ag_. Keep it for the next step.

    agent.py
    import os
    from mistralai.client import Mistral
    client = Mistral(os.environ["MISTRAL_API_KEY"])
    research_agent = client.beta.agents.create(
    model="mistral-medium-latest",
    name="research-agent",
    description="Answers questions from the live web with a written report and its sources.",
    instructions=(
    "Use web_search for one fact or a quick answer. When the user asks for a written answer "
    "with sources, tell them the price of a report: $0.10 for quick, $0.30 for standard, "
    "$1.00 for deep. Use quick unless the user asks for more depth. Call research_report only "
    "after the user approves that price in their own message. If research_report returns a "
    "report ID in place of the report, say the report is in work and call read_research_report "
    "with that ID when the user asks for it. Show the report as the tool returns it, with its "
    "[1] markers and its Sources list."
    ),
    tools=[
    {"type": "web_search"},
    {
    "type": "function",
    "function": {
    "name": "research_report",
    "description": (
    "Write a research report that answers one question from the live web, with "
    "numbered sources. Costs $0.10 (quick), $0.30 (standard) or $1.00 (deep), so "
    "call it only after the user approved the price. Returns the report text, or "
    "a report ID when the report needs more time."
    ),
    "parameters": {
    "type": "object",
    "properties": {
    "question": {
    "type": "string",
    "description": "The question the report answers, in the user's words, with the scope, the dates and the places that matter.",
    },
    "depth": {
    "type": "string",
    "enum": ["quick", "standard", "deep"],
    "description": "Use quick unless the user asked for more depth.",
    },
    },
    "required": ["question", "depth"],
    },
    },
    },
    {
    "type": "function",
    "function": {
    "name": "read_research_report",
    "description": "Read a research report by its report ID: the finished text with its sources, or the stage it is in. Costs nothing.",
    "parameters": {
    "type": "object",
    "properties": {
    "report_id": {
    "type": "string",
    "description": "The report ID that research_report returned. It starts with rr_.",
    },
    },
    "required": ["report_id"],
    },
    },
    },
    ],
    )
    print(research_agent.id)
  4. 4. Answer each function call

    Save this as chat.py. It starts a conversation with client.beta.conversations.start and continues it with client.beta.conversations.append. When an output has the type function.call, the script runs the function named in it and sends the result back as a FunctionResultEntry with the same tool_call_id. It answers every call in the reply, so two calls in one turn both get their result.

    Mistral runs web_search on its own servers. Only your two functions reach this loop.

    chat.py
    import json
    import os
    import sys
    from mistralai.client import FunctionResultEntry, Mistral
    from specstream_research import read_research_report, research_report
    TOOLS = {"research_report": research_report, "read_research_report": read_research_report}
    client = Mistral(os.environ["MISTRAL_API_KEY"])
    agent_id = sys.argv[1]
    conversation_id = None
    while True:
    inputs = [{"role": "user", "content": input("You: ")}]
    while True:
    if conversation_id is None:
    response = client.beta.conversations.start(agent_id=agent_id, inputs=inputs)
    conversation_id = response.conversation_id
    else:
    response = client.beta.conversations.append(conversation_id=conversation_id, inputs=inputs)
    calls = [output for output in response.outputs if output.type == "function.call"]
    if not calls:
    break
    inputs = []
    for call in calls:
    tool = TOOLS.get(call.name)
    result = tool(**json.loads(call.arguments)) if tool else "No such tool: " + call.name
    inputs.append(FunctionResultEntry(tool_call_id=call.tool_call_id, result=result))
    print("Agent:", response.outputs[-1].content)
  5. 5. Ask for a report

    Run the chat with the agent ID from agent.py. Ask your question. The agent names the price, and after your yes it calls research_report. The terminal waits about a minute for a quick report, then prints the report with its sources. SpecStream takes the $0.10 from your balance when the report is delivered.

    Terminal
    python chat.py ag_YOUR_AGENT_ID
    You: Write a quick research report: which EU countries cap rent increases in 2026, and what is each cap? Cite the pages.
    Agent: A quick report costs $0.10. Shall I start it?
    You: Yes, go ahead.

The two tools do different jobs, and the agent's instructions tell the model which to pick. web_search answers one fact inside the turn, on Mistral's servers. research_report plans the question, searches the live web, reads the pages and writes an answer of several paragraphs, where each cited sentence carries a [1] marker and the Sources list gives that page's title, its URL and the time it was read in this run.

web_search works with the Conversations API and the Agents API, and Mistral's docs say it is not supported in Chat Completions. A function tool works in all three. For Chat Completions, declare the same two schemas in the tools field and answer each tool call with a tool message. The Mistral API guide has that loop for web search.

On the Agents SDK

Mistral's docs now carry this notice: the Agents API v1 is about to be deprecated, and new agents are built with the Agents SDK (mistralai-agents, in public preview). The function from step 2 moves over as it is. Decorate a wrapper with @agents.tool and pass it in tools=. The SDK takes the tool's name from the function's name, its description from the docstring and its input schema from the typed parameters, so there is no JSON schema to write.

The wrappers are plain def functions, since they wait for the report. Mistral's docs say a blocking tool written as a plain def runs in its own thread. Save the file as main.py beside specstream_research.py, add the dependency with uv add requests, and run uv run --env-file .env agents chat. Put SPECSTREAM_API_KEY in the same .env as your Mistral key. To make the price approval a rule of the harness and not only an instruction, see Mistral's Approvals (opens in a new tab) page.

What the agent gets back

The function returns the report as text, which is what a model reads best: the answer with [1] markers, then the Sources list. In your own code, GET /v1/research/{id} returns more. report.citations holds each cited sentence with its page, read time and passage, and report.sources lists every page read, cited or not.

Send "output": {"type": "json", "schema": {...}} to get JSON in the shape of your schema in place of text, length to choose short, standard or long, and include_domains or exclude_domains to choose the sites. The research docs list every field.

How spending stays under control

  • One report has one fixed price for its depth. No report costs more than $1.00.
  • The user approves the price. The agent's instructions make it name the price and wait for a yes before research_report.
  • A repeated call buys nothing new. The function sends an Idempotency-Key built from the question, the depth and the hour, so the same question in the same hour returns the report it already started.
  • The charge comes at delivery. A report that fails or is cancelled costs nothing.
  • The SpecStream key stays in your process. The model sees the function's name, its schema and its result, never the key.
  • To keep deep reports out, remove "deep" from the enum in agent.py and from the depth check in specstream_research.py.

If something doesn't work

What you seeWhat to do
The report is still in workThe report needs more than the 7 minutes one call waits. Ask the agent for it again in a minute. It calls read_research_report with the report ID.
SpecStream answered 401The key is unknown, expired or revoked. Create a new key in your SpecStream workspace and set SPECSTREAM_API_KEY again.
SpecStream answered 402, or the balance cannot pay for itThe balance does not hold the report's price. Add balance in your SpecStream workspace. A report that was already written is delivered on the next read.
SpecStream answered 422A field is wrong, and the reply names it. The question holds up to 15,000 characters.
SpecStream answered 429The account used its calls for the minute. Wait and ask again. Nothing is charged.
KeyError: 'SPECSTREAM_API_KEY'The key is not set in the shell that runs chat.py. Export it there, or pass --env-file .env to uv run on the Agents SDK.
ImportError on mistralai.clientYour SDK is V1. Import Mistral and FunctionResultEntry from mistralai.
The agent starts a report without askingThe instructions were changed or left out. Create the agent again with the instructions from agent.py.

Cost

A report costs $0.10 at quick, $0.30 at standard and $1.00 at deep, taken from your SpecStream balance once, when the report is delivered. Reading a report costs nothing. Mistral bills the model's tokens as it bills any conversation, and the report text counts as input tokens when your code returns it. The rates are on pricing.

Web search and page reading too

For one fact or the text of a page as a function tool, the Mistral API guide declares a search function on the same key. The Search API is $5 per 1,000 searches of up to 10 results and the Extract API $0.80 per 1,000 pages. Mistral also registers MCP servers as Connectors: the SpecStream server is at https://api.specstream.ai/mcp, and the MCP docs list its tools, research_price, start_research and get_research among them. The REST API reference lists every call on this page.

Remove

Delete the agent in Mistral, remove the three files, and revoke the key in your SpecStream workspace. Reports already delivered stay in your account. Questions: [email protected].

More

SpecStream for Mistral Agents API Research covers what it does in Mistral Agents API Research on one page. Every other tool has its guide in Connect your tools. You see the most a request can cost before it starts, and the matches are paid from your balance. Billing has the details. For help, email [email protected].