docs
Cookbooks

Context-first reviewer

How to build a review sub-agent that pulls nima's blast radius, code context and recorded decisions for every touched file before it writes the review.

Availability

On for every workspace, with one plan note. The code tools (search_code, blast_radius, get_data_model) are declared under the plan feature code_intelligence. A workspace whose plan lacks it can get 403 INSUFFICIENT_PERMISSIONS with details.feature set to code_intelligence; handle it. ask_knowledge_graph has no plan feature.

This recipe is the review step of an agentic coding loop, with no framework. You end up with three versions of one reviewer: a Claude Code sub-agent, a Codex AGENTS.md section, and a short Python script for any other loop. For every file the diff touches, each one asks nima four questions before it writes a word of review.

  1. Where does this file sit? search_code returns ranked files, their symbols with line ranges, and the commit each index was built from.
  2. What does a change here reach? blast_radius returns dependent modules and files, the callers of the touched code in TypeScript and JavaScript, the covering tests and the specs that govern the area.
  3. What was decided about it? ask_knowledge_graph returns entities, facts and recorded contradictions.
  4. What does the data look like? get_data_model returns tables, columns and relations, and only matters when the diff touches a migration or a model.

nima never returns source code. The reviewer reads the local checkout for the line ranges nima points at.

Prerequisites for the reviewer

WhatDetail
A personal API keyCreated in the admin panel under Settings, then API keys. The four tools above all need knowledge:read. See Authentication and Scopes.
signals:write on the keyOnly for filing what the reviewer found.
An indexed repositoryGitHub connected, with the repository's indexStatus at ready. Only ready indexes are searched.
A clientClaude Code or the Codex CLI, or Python 3 for the REST script.
export NIMA_API_KEY="nima_…"
pip install requests   # only for the REST script

Connect the reviewer to nima

The reviewer connects to https://mcp.getnima.io/mcp with the key as a bearer token. The canonical connection steps for every client are on Connect to nima's MCP server and Connect a coding agent. The two configs below differ only in that they read the key from the environment, so neither file holds the secret, and they narrow the reviewer to the tools it needs.

Put this in .mcp.json at the repository root. Claude Code expands ${NIMA_API_KEY} from the environment when it starts the server.

{
  "mcpServers": {
    "nima": {
      "type": "http",
      "url": "https://mcp.getnima.io/mcp",
      "headers": {
        "Authorization": "Bearer ${NIMA_API_KEY}"
      }
    }
  }
}

The server name nima is what the tool names are built from: mcp__nima__search_code, mcp__nima__blast_radius, mcp__nima__ask_knowledge_graph and mcp__nima__get_data_model.

Define the reviewer's steps

The reviewer runs the same six steps in every client. It does not write the review until steps 1 to 4 are done.

Save this as .claude/agents/reviewer.md. Only name and description are required. The tools list limits the sub-agent to four nima tools plus Read and Grep. It has no Bash, so the parent passes the diff in the delegation prompt.

---
name: reviewer
description: Reviews a diff after pulling nima's blast radius, code context and recorded decisions for every touched file. Use for any code review.
tools: Read, Grep, mcp__nima__search_code, mcp__nima__blast_radius, mcp__nima__ask_knowledge_graph, mcp__nima__get_data_model
model: inherit
---

You review a unified diff that is given to you in the prompt. Do not write the
review until steps 1 to 4 are done.

1. List every path in the diff's `---` and `+++` lines, deduplicated.
2. For each path, call mcp__nima__search_code with q set to the path and limit 3.
   Record, per hit: repoFullName, filePath, match, summary, and each symbol's
   name, kind, lineStart and lineEnd. Record, per repository: indexStatus and
   lastCommitSha. Record every id in droppedRepositories.
3. Call mcp__nima__blast_radius once with paths set to all touched paths (at most
   50) and depth 2. Record dependentModules, dependentFiles with their callers,
   coveringTests, governingSpecs, unresolved, callGraph.importOnlyTargets and
   truncated.
4. For each path, call mcp__nima__ask_knowledge_graph with q set to
   "decisions behind " plus the top search hit's summary, or the path when there
   is no hit. Record every `###` entity heading and every `⚠ contradicted:` line.
   If the diff touches a migration or a model, call mcp__nima__get_data_model
   with table set to the table name.
5. Use Read and Grep on the local checkout for the line ranges from step 2 and
   for the callers from step 3. nima never returns source code.
6. Write the review. Start with a context block: files, symbols, dependents,
   covering tests, governing specs, index commit per repository, decisions,
   contradictions. Then the findings.

Rules:
- Say so when lastCommitSha is not the commit under review. The index follows
  the default branch only, so files added on this branch are not in it, and
  blast_radius answers about the indexed commit and carries no commit stamp of
  its own.
- If truncated is true, blast_radius returned a prefix of the dependents. Say so.
- callers is filled only for TypeScript and JavaScript. An empty callers list on
  any other language means no parser, not no callers.
- Every path in unresolved is a file nima has not indexed. Say "nima has no
  record", never "nothing depends on it".
- An empty result, a dropped repository or "No product entities found" is not
  evidence that nothing exists.
- Quote facts and contradictions as returned. Do not infer who decided what.

Delegate from the main session with the diff inline, for example: "Use the reviewer sub-agent on this diff:" followed by the output of git diff main...HEAD.

Sample calls for one touched file

These are the calls the reviewer makes for one touched file, src/upload/upload.guard.ts.

Search for the file with search_code

The reviewer passes the path as the query.

{
  "name": "search_code",
  "arguments": { "q": "src/upload/upload.guard.ts", "limit": 3 }
}

The response body, trimmed. match is exact because the hit path equals the query.

{
  "query": "src/upload/upload.guard.ts",
  "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": ["filepath", "symbol", "vector"],
      "match": "exact",
      "symbols": [
        { "name": "UploadSizeGuard", "kind": "class", "signature": null, "lineStart": 18, "lineEnd": 64 },
        { "name": "resolveSizeCeiling", "kind": "function", "signature": "resolveSizeCeiling(plan: PlanSlug): number", "lineStart": 66, "lineEnd": 81 }
      ]
    }
  ]
}

Find what the change reaches with blast_radius

The reviewer passes every touched path in one call. paths takes up to 50 paths and symbols combined, and a shorter suffix of an indexed path also matches. depth is how many import hops to walk: it defaults to 2 and goes up to 4. Either paths or symbol is required.

{
  "name": "blast_radius",
  "arguments": {
    "paths": ["src/upload/upload.guard.ts", "src/upload/limits.ts"],
    "depth": 2
  }
}

The response, trimmed to one entry per list:

{
  "targets": [
    { "repositoryId": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884", "repoFullName": "acme/orbit", "filePath": "src/upload/upload.guard.ts", "fileId": "0c1e5a52-7d34-4b86-9f0a-2e8d61b7c3f9" }
  ],
  "unresolved": [],
  "depth": 2,
  "dependentModules": [
    { "moduleId": "6a2b9d10-3c47-4e58-8b1f-90d7e4c25a63", "moduleName": "upload", "repositoryId": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884", "repoFullName": "acme/orbit", "summary": "Upload endpoints and their guards.", "edgeCount": 3, "relations": ["imports", "injects"], "depth": 1 }
  ],
  "dependentFiles": [
    {
      "repositoryId": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884",
      "repoFullName": "acme/orbit",
      "filePath": "src/upload/upload.controller.ts",
      "depth": 1,
      "relations": ["imports", "injects"],
      "moduleName": "upload",
      "callers": [{ "symbol": "UploadController.create", "lineStart": 40, "lineEnd": 72, "calls": "resolveSizeCeiling" }]
    }
  ],
  "coveringTests": [
    { "repositoryId": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884", "repoFullName": "acme/orbit", "filePath": "src/upload/__tests__/upload.guard.spec.ts", "depth": 1, "relations": ["imports"], "moduleName": "upload", "callers": [] }
  ],
  "governingSpecs": [
    { "documentId": "8d1a4f60-7b93-4c2e-a5d8-0e6f1b3c9a47", "title": "Upload size limits per plan", "status": "shipped", "updatedAt": "2026-06-01T10:22:41.000Z", "viaEntities": ["Upload size ceiling per plan"] }
  ],
  "callGraph": { "coveredTargets": ["src/upload/upload.guard.ts"], "importOnlyTargets": [], "note": "…" },
  "truncated": false
}

How to read it:

  • dependentFiles and coveringTests are split by path. Paths under __tests__/, tests/ or test/, and files named *.spec or *.test with a JavaScript or TypeScript extension, go to coveringTests. Everything else goes to dependentFiles.
  • depth on each file is the distance: 1 means it imports the target directly.
  • callers lists the symbols in that file that call into the target, with line ranges. Call edges are extracted only for .ts, .tsx, .js, .jsx, .mjs and .cjs. For every other language the dependents are import-level and callers is empty. callGraph.importOnlyTargets names those targets.
  • governingSpecs come from the knowledge graph's links between touched files or modules and spec documents.
  • truncated: true means the walk stopped at 400 impacted files and the lists are a prefix.
  • unresolved lists inputs that no indexed file or symbol matched.

blast_radius reads the existing index: it makes no model call and is not metered.

Ask for the decisions with ask_knowledge_graph

{
  "name": "ask_knowledge_graph",
  "arguments": {
    "q": "decisions behind Rejects an upload whose size is over the workspace ceiling before the file reaches storage",
    "limit": 3
  }
}

The answer comes back as markdown, trimmed. Each entity heading carries its type, a modality label and the entity id. The fact source is a bare type with an id, and a SIGNAL source also carries an origin tag.

  Knowledge graph results for: "decisions behind Rejects an upload whose size is over the workspace ceiling before the file reaches storage"

  ### Upload size ceiling per plan (technical_decision) [implemented] · id: 4e7b2c19-05d8-4a63-b1f2-8c9d3a6e7f01
  References: 9 | Similarity: 0.742
  Summary: Uploads are capped per plan and rejected before storage, not after.
  Facts:
    - reason: oversized files filled the bucket before the check ran (source: PR:5c7d9e13-2a4b-4e6f-8c1d-3b5a7e9f0c22, learned 2026-06-04)
    - ceiling_free: 25 MB (source: SPEC:8d1a4f60-7b93-4c2e-a5d8-0e6f1b3c9a47, learned 2026-06-01)
  ⚠ contradicted: ceiling_free "25 MB" (SPEC:8d1a4f60-7b93-4c2e-a5d8-0e6f1b3c9a47) vs "20 MB" (SLACK:9f3c1b2e-4d5a-4c8b-9e10-7a2f6c4d1b33), 2026-06-10
  Relations:
    - ← Northwind (customer) requested this — "their team kept hitting the limit with no message"

Read the table with get_data_model

When the diff touches a migration or a model, the reviewer asks for the table by name. The match is case-insensitive.

{
  "name": "get_data_model",
  "arguments": { "table": "uploads" }
}

Without repositoryId it answers for every indexed repository. Each repository entry carries commitSha and extractedAt. The full definition of each matching table carries its file and line range, plus columns and relations with their line, and its indexes. An extractedAt of null means nothing has been extracted for that repository yet. An empty model means no supported ORM or SQL DDL was found, not that the application stores no data.

File a finding from the reviewer

When the reviewer finds a gap that nima should track, it can file the gap as a signal with create_signal. This needs signals:write on the key, which a key does not necessarily carry. Add mcp__nima__create_signal to the sub-agent's tools, or "create_signal" to Codex's enabled_tools, only if filing is part of the reviewer's job.

{
  "name": "create_signal",
  "arguments": {
    "content": "Review of the upload guard change: the free-plan ceiling is 25 MB in the spec and 20 MB in the Slack thread the guard cites. The diff hardcodes 20 MB.",
    "sourceLabel": "code review",
    "productAreas": ["upload"]
  }
}

The new signal comes back with status pending and classification null, and nima fills in the rest asynchronously. create_signal has no idempotency key, so a retry files a second signal.

Run the reviewer over REST

The same reviewer context is available over plain HTTP, for any framework. nima_context.py reads a unified diff on stdin and extracts the touched paths. It calls GET /v1/code-intel/search and GET /v1/knowledge-graph/ask for each path, and GET /v1/code-intel/blast-radius once for all paths, then prints a short block with no prose. Pass the output to the model that writes your review.

The blast-radius route takes paths as one comma-joined query string, over REST and over MCP alike. Because commas split the list, a path that contains a comma cannot be sent.

#!/usr/bin/env python3
"""Print nima's context for a unified diff read on stdin. No prose."""
import os
import re
import sys

import requests

API = "https://api.getnima.io"
PATH_LINE = re.compile(r"^(?:\+\+\+|---) [ab]/(.+)$")


def touched_paths(diff: str) -> list[str]:
    found = (m.group(1) for m in map(PATH_LINE.match, diff.splitlines()) if m)
    return list(dict.fromkeys(found))


def print_blast_radius(s: requests.Session, paths: list[str]) -> None:
    r = s.get(f"{API}/v1/code-intel/blast-radius",
              params={"paths": ",".join(paths[:50]), "depth": 2}, timeout=60)
    r.raise_for_status()
    b = r.json()
    for f in b["dependentFiles"]:
        print(f"dependent d{f['depth']} {f['repoFullName']}:{f['filePath']}")
        for c in f["callers"]:
            print(f"  caller {c['symbol']} L{c['lineStart']}-{c['lineEnd']}")
    for t in b["coveringTests"]:
        print(f"test {t['repoFullName']}:{t['filePath']}")
    for sp in b["governingSpecs"]:
        print(f"spec {sp['title']} ({sp['status']})")
    for u in b["unresolved"]:
        print(f"unresolved {u}")
    if b["callGraph"]["importOnlyTargets"]:
        print(f"import-only {', '.join(b['callGraph']['importOnlyTargets'])}")
    if b["truncated"]:
        print("truncated true")


def main() -> None:
    s = requests.Session()
    s.headers["Authorization"] = f"Bearer {os.environ['NIMA_API_KEY']}"
    paths = touched_paths(sys.stdin.read())
    repos = {}
    for path in paths:
        r = s.get(f"{API}/v1/code-intel/search", params={"q": path, "limit": 3}, timeout=60)
        r.raise_for_status()
        body = r.json()
        repos.update({x["id"]: x for x in body["repositories"]})
        print(f"file {path}")
        for hit in body["results"]:
            print(f"  hit {hit['repoFullName']}:{hit['filePath']} match={hit['match']}")
            for sym in hit["symbols"]:
                print(f"    symbol {sym['kind']} {sym['name']} L{sym['lineStart']}-{sym['lineEnd']}")
        for rid in body["droppedRepositories"]:
            print(f"  dropped {rid}")
        top = body["results"][0]["summary"] if body["results"] else None
        kg = s.get(f"{API}/v1/knowledge-graph/ask",
                   params={"q": f"decisions behind {top or path}"[:500], "limit": 3}, timeout=60)
        kg.raise_for_status()
        for line in kg.text.splitlines():
            if line.startswith("### "):
                print(f"  entity {line[4:]}")
            elif line.startswith("⚠ contradicted: "):
                print(f"  contradiction {line[16:]}")
    if paths:
        print_blast_radius(s, paths)
    for repo in repos.values():
        print(f"index {repo['repoFullName']} {repo['indexStatus']} {repo['lastCommitSha']} {repo['lastSyncedAt']}")


if __name__ == "__main__":
    main()
git diff main...HEAD | python3 nima_context.py
  file src/upload/upload.guard.ts
    hit acme/orbit:src/upload/upload.guard.ts match=exact
      symbol class UploadSizeGuard L18-64
      symbol function resolveSizeCeiling L66-81
    entity Upload size ceiling per plan (technical_decision) [implemented] · id: 4e7b2c19-05d8-4a63-b1f2-8c9d3a6e7f01
    contradiction ceiling_free "25 MB" (SPEC:8d1a4f60-7b93-4c2e-a5d8-0e6f1b3c9a47) vs "20 MB" (SLACK:9f3c1b2e-4d5a-4c8b-9e10-7a2f6c4d1b33), 2026-06-10
  file src/upload/limits.ts
    hit acme/orbit:src/upload/limits.ts match=exact
      symbol type UPLOAD_CEILINGS L4-11
  dependent d1 acme/orbit:src/upload/upload.controller.ts
    caller UploadController.create L40-72
  test acme/orbit:src/upload/__tests__/upload.guard.spec.ts
  spec Upload size limits per plan (shipped)
  index acme/orbit ready b41c9de2f7a1c05b8e3d6410a9f2c7d3e5b81a60 2026-09-21T09:14:02.331Z

A file with no entity line got a knowledge-graph answer with no entities, usually "No product entities found". That answer is a 200 with a prose body, not an error. To file a finding over REST, use POST /v1/signals. See Signals.

blast_radius freshness

blast_radius has no commit stamp in its response. It reads the index of each repository's default branch and answers about the commit that index was built from, so an index behind HEAD gives an answer about older code. Files added on the branch under review are not in the index and come back in unresolved. Call search_code in the same review and compare lastCommitSha to the commit under review before you trust any structural claim. Callers exist only for TypeScript and JavaScript.

Reviewer gotchas

SymptomCause
A file the diff adds has no hit and appears in unresolvedThe index follows the default branch only. Files that exist only on the branch appear after merge.
callers is empty on every dependent of a changed Python or Go fileCall edges are extracted only for TypeScript and JavaScript. For other languages dependentFiles lists the files that import the target, and callGraph.importOnlyTargets names those targets. An empty callers there means no parser, not no callers.
truncated is trueThe walk stopped at 400 impacted files. Lower depth or pass fewer paths to see a tighter set.
blast_radius answers 400 with paths or symbol is requiredNeither paths nor symbol was sent. More than 50 paths and symbols together is refused too: over REST as 400, over MCP as an Invalid arguments tool error.
results is empty for a file that existsOnly ready indexes are searched. droppedRepositories lists repositories that failed or took longer than 45 seconds. An empty result does not mean the file is missing.
A summary contradicts the diffSummaries are written at index time from the start of the file. Trust the checkout.
The knowledge graph answers but names nothingWhen nothing scores high enough, it answers 200 with "No product entities found". Check for that prefix, or for the absence of a ### heading.
A reviewer tool is missing from the tool listThe client's allowlist hides it (the sub-agent's tools line or Codex enabled_tools), or the server is not named nima, so the mcp__nima__… names do not match.
A reviewer tool call fails with 403 and insufficient_scopeThe key lacks knowledge:read (or signals:write for create_signal). The tool still appears in the list; create a key with the scope.
403 with details.feature set to code_intelligenceThe three code tools are declared under the plan feature code_intelligence, and the workspace's plan lacks it. The key is fine, and ask_knowledge_graph still works.
429 in a large diffEach route has its own counter. Over MCP, every tool call also counts against the POST /mcp limit of 100 per 60 seconds, which is the tighter one for an MCP reviewer. When the inner route is limited, MCP answers 200 and flags the tool result as an error whose text starts with 429. See Rate limits.
Two identical signalscreate_signal has no idempotency key. Do not retry it blindly.

On this page