docs
Cookbooks

OpenAI Agents SDK

Connect nima's MCP server to an OpenAI Agents SDK agent, filter it to read tools, and fall back to plain HTTP.

At the end of this page you have a Python agent built on the OpenAI Agents SDK that connects to https://mcp.getnima.io/mcp with a personal API key. Before it answers, it asks ask_knowledge_graph why and who, and search_code where. With the right scope, a second agent files what it found as a signal with create_signal. The last section does the same over https://api.getnima.io with plain HTTP calls wrapped as function tools.

Availability

On for every workspace. Any member can create a personal API key in Settings. search_code and the other code routes are declared under the Code Intelligence plan feature: a workspace whose plan lacks it can get 403 INSUFFICIENT_PERMISSIONS with details.feature set to code_intelligence, so handle that answer.

Prerequisites for the OpenAI Agents SDK cookbook

WhatValue
nima keyA personal API key (nima_...) from Settings, then API keys. The key form preselects knowledge:read and signals:read, and this page assumes those two. Add signals:write only for File a signal from an OpenAI agent.
OpenAI keyOPENAI_API_KEY, which the SDK reads from the environment.
Python packageopenai-agents
Code indexsearch_code needs at least one repository whose index is ready, and a plan with Code Intelligence.
pip install openai-agents requests
export OPENAI_API_KEY=sk-...
export NIMA_API_KEY=nima_...

requests is only for the REST section. Keep the nima key in a secret store, never in a repository. See Authentication.

Connect an OpenAI agent to nima's MCP server

MCPServerStreamableHttp from agents.mcp takes a params dict with url and headers. The bearer goes in headers. This file connects, lists the tools your key can see, and exits.

import asyncio
import os

from agents.mcp import MCPServerStreamableHttp

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"


def nima_server(api_key: str, **kwargs) -> MCPServerStreamableHttp:
    return MCPServerStreamableHttp(
        name="nima",
        params={
            "url": NIMA_MCP_URL,
            "headers": {"Authorization": f"Bearer {api_key}"},
            "timeout": 70,
        },
        cache_tools_list=True,
        # nima gives a tool call up to 60 seconds. The SDK default is 5.
        client_session_timeout_seconds=70,
        **kwargs,
    )


async def main() -> None:
    api_key = os.environ["NIMA_API_KEY"]
    async with nima_server(api_key) as server:
        tools = await server.list_tools()
        for tool in tools:
            print(tool.name)


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

nima lists every tool behind a consent scope on tools/list, whether or not the key holds that scope. A default key therefore sees about 30 tools, not just the nine that knowledge:read and signals:read can call. Calling a tool whose scope the key lacks answers 403. Tools that need a headless scope appear only when the key holds it. Restrict what the agent can reach with a tool filter, as in the next section. The MCP server overview has the full visibility rule.

Hosted MCP or a client connection in the OpenAI Agents SDK

The SDK reaches a remote MCP server in more than one way. It also has local stdio servers (MCPServerStdio), which do not apply to nima.

FormWho opens the connectionUse it for nima when
MCPServerStreamableHttpYour process, with the headers you setAlways. This is the form this page uses.
MCPServerSseYour process, with the headers you setNot needed. nima serves Streamable HTTP at /mcp.
HostedMCPTool with tool_config ("type": "mcp", "server_label", "server_url", "require_approval")OpenAI's Responses API, calling the server for youOnly when you want OpenAI to hold the key. Put the bearer in headers inside tool_config.
from agents import HostedMCPTool

nima_hosted = HostedMCPTool(
    tool_config={
        "type": "mcp",
        "server_label": "nima",
        "server_url": "https://mcp.getnima.io/mcp",
        "headers": {"Authorization": f"Bearer {api_key}"},
        "require_approval": "never",
        # The tool filter goes here, not in tool_filter.
        "allowed_tools": ["ask_knowledge_graph", "search_code"],
    }
)

With this form OpenAI's servers hold the nima key and call nima on your behalf. This page recommends the client connection, because the key stays in your process. nima needs only the bearer.

Ask nima before acting in an OpenAI agent

A reviewer needs only two tools. create_static_tool_filter from agents.mcp limits the agent to ask_knowledge_graph and search_code, even though the key could reach more tools.

import asyncio
import os
import sys

from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"

REVIEWER_INSTRUCTIONS = """\
Before writing the review, call search_code with the path or symbol of each changed area.
It returns ranked files with a one-line summary, the top symbols and their line ranges, and
per repository the indexed commit and its last sync time. Compare that commit against the one
under review and say so in the review when the index is behind. Then call ask_knowledge_graph
with the feature name: it returns markdown listing matching entities, their facts and
relationships, and unresolved conflicts. Neither tool reads the diff. Quote, do not infer.
"""


async def main(question: str) -> None:
    api_key = os.environ["NIMA_API_KEY"]
    async with MCPServerStreamableHttp(
        name="nima",
        params={
            "url": NIMA_MCP_URL,
            "headers": {"Authorization": f"Bearer {api_key}"},
            "timeout": 70,
        },
        cache_tools_list=True,
        client_session_timeout_seconds=70,
        tool_filter=create_static_tool_filter(
            allowed_tool_names=["ask_knowledge_graph", "search_code"]
        ),
    ) as server:
        agent = Agent(
            name="Reviewer",
            instructions=REVIEWER_INSTRUCTIONS,
            mcp_servers=[server],
        )
        result = await Runner.run(agent, question)
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(
        main(
            " ".join(sys.argv[1:])
            or "Review a change to CSV import chunking at commit b41c9de2."
        )
    )

The agent decides the calls. For the prompt above, a typical first call and its trimmed result:

{
  "tool": "search_code",
  "arguments": { "q": "where is the csv import chunked", "limit": 3 }
}
{
  "query": "where is the csv import chunked",
  "repositories": [
    {
      "id": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884",
      "repoFullName": "acme/orbit",
      "indexStatus": "ready",
      "lastCommitSha": "b41c9de2f7a1c05b8e3d6410a9f2c7d3e5b81a60",
      "lastSyncedAt": "2026-09-21T09:14:02.331Z",
      "indexWarnings": []
    }
  ],
  "droppedRepositories": [],
  "results": [
    {
      "repositoryId": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884",
      "repoFullName": "acme/orbit",
      "filePath": "src/import/stream-reader.ts",
      "score": 0.79,
      "summary": "Reads an uploaded CSV in fixed-size chunks and hands each to the row parser.",
      "matchedChannels": ["vector", "symbol"],
      "match": "semantic",
      "symbols": [
        {
          "name": "readChunks",
          "kind": "function",
          "signature": "readChunks(file: Upload, size: number): AsyncIterable<Row[]>",
          "lineStart": 18,
          "lineEnd": 64
        }
      ]
    }
  ]
}

Then the knowledge graph:

{
  "tool": "ask_knowledge_graph",
  "arguments": { "q": "why did we drop the legacy importer", "limit": 2 }
}

The answer is markdown. This sample is trimmed and indented two spaces:

  Knowledge graph results for: "why did we drop the legacy importer"

  ### Legacy importer (feature) [implemented] · id: 9a2e4c61-0b7d-4f3a-8e15-6c9d2b7f4a08
  References: 14 | Similarity: 0.781
  Summary: First-generation CSV importer, replaced by the streaming importer in March.
  Facts:
    - status: removed (source: SPEC:7d1c3e95-4a26-4b8f-9f02-e6a15b3c8d47, learned 2026-03-11)
    - replaced_by: streaming importer (source: PR:5c7d9e13-2a4b-4e6f-8c1d-3b5a7e9f0c22, learned 2026-03-19)
  ⚠ contradicted: owner "Platform" (SPEC:7d1c3e95-4a26-4b8f-9f02-e6a15b3c8d47) vs "Data" (PR:5c7d9e13-2a4b-4e6f-8c1d-3b5a7e9f0c22), 2026-04-02

The code result is JSON. The knowledge graph answer is markdown, which the model reads without parsing. Pass "mode": "agent" to ask_knowledge_graph when a program, not a model, reads the answer. lastCommitSha is the commit the index was built from: compare it with the commit under review. For a path or symbol query, match: "none" means no channel confirmed that path or symbol exists; the hit is only a neighbour. Only ready repositories are searched, so an empty results is not proof the code does not exist. The code search reference and the knowledge graph API describe every field and line shape.

File a signal from an OpenAI agent

create_signal files one signal into the pipeline. It needs a key that carries signals:write. Give the write to a separate agent so the reviewer stays read-only. The tool filter here allows only create_signal.

import asyncio
import os

from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"


async def main() -> None:
    api_key = os.environ["NIMA_API_KEY"]  # must carry signals:write
    async with MCPServerStreamableHttp(
        name="nima",
        params={
            "url": NIMA_MCP_URL,
            "headers": {"Authorization": f"Bearer {api_key}"},
            "timeout": 70,
        },
        client_session_timeout_seconds=70,
        # Keep the default of 0 retries: a retried create files a second signal.
        max_retry_attempts=0,
        tool_filter=create_static_tool_filter(allowed_tool_names=["create_signal"]),
    ) as server:
        agent = Agent(
            name="Filer",
            instructions=(
                "File exactly one signal with create_signal. Put the finding in the "
                "reporter's words in content, where it came from in sourceLabel, and "
                "the affected customers and product areas in stakeholders and "
                "productAreas. Report the id nima returns."
            ),
            mcp_servers=[server],
        )
        result = await Runner.run(
            agent,
            "Northwind reports the weekly CSV import dies around 40k rows with a 504. "
            "They split the file by hand. Heard on a customer call.",
        )
        print(result.final_output)


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

The call the agent makes:

{
  "tool": "create_signal",
  "arguments": {
    "content": "On the Northwind call: the weekly CSV import dies around 40k rows with a 504. They are splitting the file by hand.",
    "sourceLabel": "customer call",
    "stakeholders": ["Northwind"],
    "productAreas": ["import"]
  }
}

The signal comes back with status: "pending". nima classifies it asynchronously. Limits on the tool input:

FieldLimit
contentRequired, 1 to 10,000 characters.
sourceLabelAt most 100 characters. Defaults to manual.
stakeholdersAt most 10 entries of at most 80 characters.
productAreasAt most 10 entries of at most 80 characters.

contentBlocks is accepted only by the HTTP route, not by the tool. See Signals for the route and Signal, beacon and spec MCP tools for the tool.

Call nima over REST from an OpenAI agent

Every tool is one route on https://api.getnima.io, called with the same header. Wrap the routes as function tools and the agent needs no MCP connection. In the Agents SDK, function_tool from agents is the decorator, and the docstring becomes the tool description.

import asyncio
import json
import os

import requests

from agents import Agent, Runner, function_tool

NIMA_API = "https://api.getnima.io"


def _headers() -> dict[str, str]:
    return {"Authorization": f"Bearer {os.environ['NIMA_API_KEY']}"}


@function_tool
def ask_knowledge_graph(q: str, limit: int = 8) -> str:
    """Ask nima why something is the way it is and who asked for it.

    Args:
        q: The question in plain English, 2 to 500 characters.
        limit: How many entities to return, 1 to 20.
    """
    response = requests.get(
        f"{NIMA_API}/v1/knowledge-graph/ask",
        headers=_headers(),
        params={"q": q, "limit": limit},
        timeout=70,
    )
    response.raise_for_status()
    # Markdown, not JSON.
    return response.text


@function_tool
def search_code(q: str, limit: int = 8) -> str:
    """Find where something is implemented in the indexed repositories.

    Args:
        q: What to find, in plain English or as an identifier, 2 to 300 characters.
        limit: File hits to return, at most 20.
    """
    response = requests.get(
        f"{NIMA_API}/v1/code-intel/search",
        headers=_headers(),
        params={"q": q, "limit": limit},
        timeout=70,
    )
    response.raise_for_status()
    return json.dumps(response.json())


@function_tool
def create_signal(content: str, source_label: str, product_areas: list[str]) -> str:
    """File one signal in nima. Needs a key with signals:write.

    Args:
        content: The finding in the reporter's own words, at most 10,000 characters.
        source_label: Where it came from, at most 100 characters.
        product_areas: What it touches, at most 10 entries.
    """
    response = requests.post(
        f"{NIMA_API}/v1/signals",
        headers=_headers(),
        json={
            "content": content,
            "sourceLabel": source_label,
            "productAreas": product_areas,
        },
        timeout=70,
    )
    response.raise_for_status()
    return json.dumps({"id": response.json()["id"]})


async def main() -> None:
    agent = Agent(
        name="Reviewer",
        instructions=(
            "Call search_code for each changed area and ask_knowledge_graph for the "
            "feature before answering. Quote, do not infer."
        ),
        tools=[ask_knowledge_graph, search_code],
    )
    result = await Runner.run(agent, "Review a change to CSV import chunking.")
    print(result.final_output)


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

Add create_signal to tools only when the key carries signals:write. Over HTTP, a missing scope answers 403 with INSUFFICIENT_PERMISSIONS. The requests calls block, so an async agent that must stay responsive should run them in a thread or use an async HTTP client. The other tools have routes too: see the API overview for the map.

Fix common OpenAI Agents SDK problems with nima

ProblemFix
client_session_timeout_seconds defaults to 5 seconds. nima gives a tool call up to 60, and a knowledge graph answer can take longer than 5.Set it above 60, as the examples do.
The agent can call tools you did not intend, such as write tools.tools/list shows every consent-scope tool. Pass tool_filter=create_static_tool_filter(allowed_tool_names=[...]) to limit the agent.
A tool call answers 403 with insufficient_scope.The key lacks that tool's scope. Create a key with it. Naming the tool in a tool_filter does not grant it.
HostedMCPTool returns 401.The bearer is missing from tool_config["headers"], or the key was revoked.
Two signals for one finding.POST /v1/signals has no idempotency key. Leave max_retry_attempts at 0 on any server that exposes create_signal.
search_code answers 403.The plan lacks Code Intelligence, or the key lacks knowledge:read.
search_code returns nothing, or out-of-date files and symbols.Only ready indexes are searched, and each is searched at its lastCommitSha. Check droppedRepositories and the commit before concluding.
429 with two keys on one machine.Rate limits count by client IP, not by key. Callers behind a shared egress share a bucket.
Results from the wrong workspace.A key acts in the one workspace it was created in. Create a key there.

On this page