Anthropic Claude SDK
Give a Claude agent built on the Anthropic SDK nima's knowledge graph and code search, through the MCP connector or tool use over REST, and file what it finds as a signal.
By the end of this page you have a Claude agent, in Python or TypeScript, that asks nima why a part of your product is the way it is and where it lives in the code before it proposes a change, and that files what it finds as a signal. There are two ways to wire it. The MCP connector hands nima's MCP server to the Messages API, and Claude calls the tools on Anthropic's side. Classic tool use defines ask_knowledge_graph and search_code in your code and runs them over nima's REST API. Both use the same personal API key.
Availability
On for every workspace. The knowledge graph tool needs no setup. search_code needs a GitHub repository connected and indexed.
Prerequisites for the Anthropic SDK cookbook
| What | Value |
|---|---|
| nima key | A personal API key (nima_…), created in the admin panel under Settings → API keys. The form checks knowledge:read and signals:read by default. Add signals:write to file signals. |
| Code search | search_code is declared under the Code Intelligence plan feature. A workspace whose plan lacks it can get 403 with details.feature set to code_intelligence; handle it. |
| Anthropic key | ANTHROPIC_API_KEY in the environment. The SDK reads it. |
| Model | claude-opus-5-5 |
export NIMA_API_KEY="nima_…"
export ANTHROPIC_API_KEY="sk-ant-…"pip install anthropic requestsConnect the Anthropic MCP connector to nima
The MCP connector is a beta feature of the Messages API, turned on with the mcp-client-2025-11-20 beta header. It takes two things: a server in mcp_servers, and an mcp_toolset in tools that names that server. A server with no toolset is rejected.
| Field | Value for nima |
|---|---|
mcp_servers[].type | "url" |
mcp_servers[].url | https://mcp.getnima.io/mcp |
mcp_servers[].name | "nima", any unique name |
mcp_servers[].authorization_token | Your nima key |
tools[].type | "mcp_toolset" |
tools[].mcp_server_name | The same name, "nima" |
authorization_token is documented as an OAuth token
Anthropic's MCP connector documentation describes authorization_token as an OAuth access token and says the connector supports OAuth Bearer tokens. It does not show the literal header it sends, and it has no field for arbitrary headers. nima accepts a personal key and an OAuth access token in the same Authorization: Bearer header and routes a token starting with nima_ to the key check. Passing the key here is an inference from those two facts, not a documented form: neither Anthropic nor nima documents sending a personal key through the connector. It has not been verified against nima. If the connector fails to authenticate, use REST instead of MCP, which sends the header from your own code.
This file connects and asks Claude which nima tools it can see.
import os
import anthropic
NIMA_MCP_URL = "https://mcp.getnima.io/mcp"
MODEL = "claude-opus-5-5"
def main() -> None:
nima_key = os.environ["NIMA_API_KEY"]
client = anthropic.Anthropic()
response = client.beta.messages.create(
model=MODEL,
max_tokens=16000,
messages=[
{
"role": "user",
"content": "List the nima tools you can call, one per line.",
}
],
mcp_servers=[
{
"type": "url",
"url": NIMA_MCP_URL,
"name": "nima",
"authorization_token": nima_key,
}
],
tools=[{"type": "mcp_toolset", "mcp_server_name": "nima"}],
betas=["mcp-client-2025-11-20"],
)
for block in response.content:
if block.type == "text":
print(block.text)
if __name__ == "__main__":
main()With a default key the answer names the 30 tools that sit behind nima's consent scopes, not only the nine your scopes cover. nima lists every consent-scope tool to any key, held or not, and checks the scope when the tool is called. Tools behind the headless scopes (operations:write, integrations:write, and the two that cannot be minted today) appear only when the key holds them. The MCP tool reference lists each tool: knowledge tools and pipeline tools.
Ask nima why and where before Claude acts
Allowlisting keeps the model's tool list to the two tools the agent needs. default_config with enabled: false turns every tool off; configs turns the named ones back on. Tell the agent in the system prompt when to call them.
import json
import os
import anthropic
NIMA_MCP_URL = "https://mcp.getnima.io/mcp"
MODEL = "claude-opus-5-5"
SYSTEM = (
"Before you propose a change, call ask_knowledge_graph for why the area is "
"the way it is and who asked for it, then search_code for where it lives. "
"Quote what they return. search_code reports the commit each index was built "
"from as lastCommitSha; say so when it may be behind. An empty answer is not "
"proof that nothing exists."
)
QUESTION = "We want to raise the upload size limit. What should I know first?"
def main() -> None:
nima_key = os.environ["NIMA_API_KEY"]
client = anthropic.Anthropic()
response = client.beta.messages.create(
model=MODEL,
max_tokens=16000,
system=SYSTEM,
messages=[{"role": "user", "content": QUESTION}],
mcp_servers=[
{
"type": "url",
"url": NIMA_MCP_URL,
"name": "nima",
"authorization_token": nima_key,
}
],
tools=[
{
"type": "mcp_toolset",
"mcp_server_name": "nima",
"default_config": {"enabled": False},
"configs": {
"ask_knowledge_graph": {"enabled": True},
"search_code": {"enabled": True},
},
}
],
betas=["mcp-client-2025-11-20"],
)
for block in response.content:
if block.type == "mcp_tool_use":
print(f"→ {block.name} {json.dumps(block.input)}")
elif block.type == "mcp_tool_result":
if isinstance(block.content, str):
text = block.content
else:
text = "".join(part.text for part in block.content if part.type == "text")
print(f"← {'error: ' if block.is_error else ''}{text[:300]}")
elif block.type == "text":
print(block.text)
if __name__ == "__main__":
main()Claude runs the calls itself. They come back in response.content as mcp_tool_use and mcp_tool_result blocks, next to its text. The call to the knowledge graph looks like this:
{
"type": "mcp_tool_use",
"id": "mcptoolu_01Hq7ZkP3xVb9aTn2cLr8sWd",
"name": "ask_knowledge_graph",
"server_name": "nima",
"input": { "q": "why is the upload size limit set per plan", "limit": 3 }
}Its result is markdown inside a text block. Trimmed:
{
"type": "mcp_tool_result",
"tool_use_id": "mcptoolu_01Hq7ZkP3xVb9aTn2cLr8sWd",
"is_error": false,
"content": [
{
"type": "text",
"text": "Knowledge graph results for: \"why is the upload size limit set per plan\"\n\n### Upload size ceiling (constraint) [implemented] · id: 7c1e9a52-0d4b-4f3a-9e61-2b8d5f0a3c17\nReferences: 9 | Similarity: 0.742\nSummary: Per-plan cap on a single upload, enforced before the file reaches storage.\nFacts:\n - reason: storage cost on the free plan (source: SPEC:3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884, learned 2026-05-12)\n - requested_raise: Northwind asked for 2 GB files (source: SIGNAL:b4f2a1d0-3c7e-4a19-8f55-6d21c0e9b7aa, learned 2026-08-30)\n…"
}
]
}Then the code search:
{
"type": "mcp_tool_use",
"id": "mcptoolu_01Rt4MfW6yGc2kDp8nXa5bQe",
"name": "search_code",
"server_name": "nima",
"input": { "q": "where is the upload size limit enforced", "limit": 5 }
}{
"query": "where is the upload size limit enforced",
"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/upload/upload.guard.ts",
"score": 0.4128,
"summary": "Rejects an upload whose size is over the workspace ceiling before the file reaches storage, and records the rejection.",
"matchedChannels": ["keyword", "symbol", "vector"],
"match": "semantic",
"symbols": [
{
"name": "resolveSizeCeiling",
"kind": "function",
"signature": "resolveSizeCeiling(plan: PlanSlug): number",
"lineStart": 66,
"lineEnd": 81
}
]
}
]
}Compare lastCommitSha with the commit you are on before you trust a line range. Code search returns paths, summaries and symbols, never source, so read the file from your own checkout.
File what the Claude agent found as a nima signal
create_signal files a signal into the workspace. It needs signals:write on the key; without it the call is refused with insufficient_scope, and naming the tool in configs does not grant the scope. Enable it next to the two read tools and tell the agent when to use it.
tools=[
{
"type": "mcp_toolset",
"mcp_server_name": "nima",
"default_config": {"enabled": False},
"configs": {
"ask_knowledge_graph": {"enabled": True},
"search_code": {"enabled": True},
"create_signal": {"enabled": True},
},
}
],Add a line to the system prompt such as "When you find a bug you are not going to fix now, file it with create_signal, once." The call Claude makes:
{
"type": "mcp_tool_use",
"id": "mcptoolu_01Lc9VnQ2eJx7hTa4pKs6mYf",
"name": "create_signal",
"server_name": "nima",
"input": {
"content": "resolveSizeCeiling in src/upload/upload.guard.ts returns the free-plan ceiling when the plan slug is unknown, so a new plan inherits the smallest limit.",
"sourceLabel": "coding agent",
"productAreas": ["upload"]
}
}The signal lands as source: "manual" in status: "pending"; nima classifies it asynchronously. There is no idempotency key, so a retried call files a second signal. content holds up to 10,000 characters.
Call nima over REST instead of the MCP connector
Classic tool use keeps the nima call in your code, so it works when the connector cannot authenticate with a personal key. You define the two tools, Claude answers with stop_reason: "tool_use" and tool_use blocks, your code calls nima with the bearer header, and you send back one user message holding every tool_result. No beta header is involved, and the key never leaves your process.
| Tool | Route | Returns |
|---|---|---|
ask_knowledge_graph | GET https://api.getnima.io/v1/knowledge-graph/ask?q=…&limit=… | text/markdown |
search_code | GET https://api.getnima.io/v1/code-intel/search?q=…&repositoryId=…&limit=… | JSON |
import os
import anthropic
import requests
NIMA_API = "https://api.getnima.io"
MODEL = "claude-opus-5-5"
MAX_TURNS = 10
SYSTEM = (
"Before you propose a change, call ask_knowledge_graph for why and who, then "
"search_code for where. Quote what they return. An empty answer is not proof "
"that nothing exists."
)
TOOLS = [
{
"name": "ask_knowledge_graph",
"description": (
"Ask nima's knowledge graph a plain-English question about the product: "
"why a decision was made, who asked for something, what customers said. "
"Returns markdown with entities, sourced facts, relationships and "
"unresolved contradictions."
),
"input_schema": {
"type": "object",
"properties": {
"q": {"type": "string", "description": "The question, 2 to 500 characters."},
"limit": {"type": "integer", "description": "Entities to return, 1 to 20. Default 8."},
},
"required": ["q"],
},
},
{
"name": "search_code",
"description": (
"Search the repositories nima has indexed. Returns ranked files with a "
"summary, symbols and line ranges, plus per repository the commit the "
"index was built from (lastCommitSha). Never returns source code."
),
"input_schema": {
"type": "object",
"properties": {
"q": {"type": "string", "description": "What to find, at least 2 characters."},
"repositoryId": {"type": "string", "description": "A repository id from an earlier response."},
"limit": {"type": "integer", "description": "File hits to return, 1 to 20. Default 8."},
},
"required": ["q"],
},
},
]
ROUTES = {
"ask_knowledge_graph": "/v1/knowledge-graph/ask",
"search_code": "/v1/code-intel/search",
}
def run_tool(name: str, tool_input: dict, nima_key: str) -> tuple[str, bool]:
path = ROUTES.get(name)
if path is None:
return f"Unknown tool: {name}", True
try:
response = requests.get(
f"{NIMA_API}{path}",
headers={"Authorization": f"Bearer {nima_key}"},
params=tool_input,
timeout=60,
)
except requests.RequestException as err:
return f"Could not reach nima: {err}", True
if response.status_code >= 400:
return f"nima answered {response.status_code}: {response.text[:500]}", True
return response.text, False
def main() -> None:
nima_key = os.environ["NIMA_API_KEY"]
client = anthropic.Anthropic()
messages = [
{"role": "user", "content": "We want to raise the upload size limit. What should I know first?"}
]
for _ in range(MAX_TURNS):
response = client.messages.create(
model=MODEL,
max_tokens=16000,
system=SYSTEM,
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"→ {block.name} {block.input}")
content, is_error = run_tool(block.name, block.input, nima_key)
results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": content,
"is_error": is_error,
}
)
messages.append({"role": "user", "content": results})
if response.stop_reason not in ("end_turn", "tool_use"):
print(f"Stopped: {response.stop_reason}")
for block in response.content:
if block.type == "text":
print(block.text)
if __name__ == "__main__":
main()Filing a signal over REST is one POST with signals:write:
curl -X POST https://api.getnima.io/v1/signals \
-H "Authorization: Bearer $NIMA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "resolveSizeCeiling returns the free-plan ceiling when the plan slug is unknown.",
"sourceLabel": "coding agent",
"productAreas": ["upload"]
}'The answer is 201 with the created signal in status: "pending".
Known problems when a Claude agent calls nima
| What | Why it bites |
|---|---|
| Key restricted to IP ranges | With the MCP connector, nima is called from Anthropic's servers, not yours. nima checks the client address its load balancer records, so a key allowlisted to your network answers 403. Use an unrestricted key for the connector, or the REST path. |
create_signal refused | The key lacks signals:write. The tool is still listed; the call fails. Allowlisting it in configs does not add the scope. |
search_code answers 403 | The workspace's plan lacks the Code Intelligence feature. The message names the feature, not the scope. |
search_code returns no results | No repository is indexed yet, or the search timed out per repository. The response is still 200; look at droppedRepositories and indexStatus. |
| An empty knowledge graph answer | Still 200 and text/markdown. It contains No product entities found for: (after a translation note when the question was translated) or starts Could not generate an embedding for:. Check for a ### heading, not the status. |
| Stale code index | The index follows each default branch. Compare lastCommitSha with your commit before trusting structure. |
Knowledge graph 400 | A bad q or limit is rejected with a generic Bad Request. Keep q between 2 and 500 characters and limit between 1 and 20. |
| Tool timeout | A call through nima's MCP endpoint gets 60 seconds. A timeout or server error comes back as a tool error with no reason: a timeout says only "upstream error", a server error only its status. |
| Rate limits | Counted per calling address, not per key. The knowledge graph, code search and signal creation routes allow 300 requests per 10 seconds, but every MCP tool call also passes through POST /mcp, which allows 100 per minute. Behind a shared egress address, callers share the bucket. |
Retried create_signal | No idempotency key. A retry files a second signal. |
| Where the connector runs | The connector is a beta feature of Anthropic's API, and its availability by platform and its data-retention terms are Anthropic's to state. Check Anthropic's documentation. |
Knowledge tools
ask_knowledge_graph and search_code, with their inputs.
Pipeline tools
create_signal and the signal and beacon tools.
Connect nima to Claude
The MCP endpoint, the consent screen and permissions.
Scopes
Which scope each tool and route needs.
Rate limits
The three throttle tiers and what a limited call returns.