# Code index

Source: https://opvs.ai/docs/guides/code-index

> When a project has a GitHub repository attached, OPVS keeps a code index of it: every function, class, method and type it can find, with its file, its line…

When a project has a GitHub repository attached, OPVS keeps a **code index** of it: every function, class, method and type it can find, with its file, its line range, its signature and the first line of its doc comment. The index is rebuilt after each merge to the attached branch, so it describes the code as it is now, not as it was when the project was set up.

The index is **structural**. A parser reads the tree, and nothing else happens to your code: no source is sent to a model provider, and the index holds no summaries and no embeddings.

Two things read it:

- **Every build.** Each build gets a generated map of the repository in

`.opvs-runner/code-map.md` beside its checkout, naming the branch and commit it describes, and one line in the build's brief that points at it. The file is ignored by git, so it never reaches a pull request, and OPVS never writes a second `CLAUDE.md`: if your repository has one, it stays the only briefing at the root.

- **Your agents, through the endpoints on this page.** Before an agent writes a function, it can

ask whether one already exists, and where. Before it changes one, it can ask who calls it.

Each index also reads your repository's knowledge files (`CLAUDE.md`, `AGENTS.md`, `LEARNINGS.md` and similar) and keeps them as lessons in AgentMemory. Unlike the index itself, that stores the files' text and sends it to an embedding model. See [Your repo's knowledge in AgentMemory](/developers/guides/repo-knowledge), which also covers a repository no machine indexes.

Base path: `/api/v1/code`. Authentication: a brand-scoped PAT or a dashboard session. See [Authentication](/developers/authentication). Every route on this page needs the `code:read` scope, and every route but `POST /callers` also answers `?format=yaml` or `?format=md` with the same content.

## How the index is built

1. **Attach a GitHub repository to a project, with a branch.** A brand admin does this with

`PUT /api/v1/builders/projects/{project_id}/repo`. The attached branch is the one that is indexed. A repository attached without a branch is not indexed.

1. **A merge queues an index job.** A push to the attached branch queues a job within seconds, and

a check every five minutes queues one for any commit that was missed.

1. **One of your workspace's build machines runs the job.** It downloads that commit's archive,

reads it and sends back the result. Jobs are only ever run by your own workspace's machines, so a job waits in `queued` until one of them is online.

1. **The new index takes over when it is ready.** Until then, queries answer from the previous

index, and every answer names the commit it describes.

Only repositories on GitHub are indexed. A repository on any other host is skipped with the reason `unsupported_host`.

**What the index reads.** Python is read with a full parser. TypeScript, JavaScript and Go are read by matching declaration patterns, which finds most declarations but not every one. Each symbol says which of the two found it, in `derivation` (`parsed` or `regex`). SQL, shell, YAML, JSON, Markdown, CSS, HTML, Rust, Ruby, Java and PHP files count toward the file and line totals but add no symbols. Files your `.gitignore` excludes are not read.

**Size limit.** One index holds up to 50,000 symbols. In a larger repository the limit is shared across directories, so every directory is read and each one is under-counted, rather than some being skipped. The index then reports `index_truncated: true` with the reason, and every count it gives is a lower bound. The limit applies to each index build, and a repository past it is still indexed: the index is stored as truncated, never refused, and every route keeps answering from it.

### Leave code out of the index with .opvsindexignore

Vendored trees, generated code and tests are part of a repository but not the code an agent should treat as the project's own, and in a large repository they spend most of the 50,000-symbol budget. List them in a `.opvsindexignore` file at the repository root:

- **Syntax:** the same as `.gitignore`. One pattern per line and `#` starts a comment. A pattern

with no `/` except a trailing one (`tests/`, `*.spec.*`) matches at any depth; a pattern with a `/` inside it (`docs/archive/`) is matched from the repository root. A trailing `/` matches directories only, and `!` re-includes a path an earlier line excluded; the last matching line wins.

- **Where:** the repository root only. A `.opvsindexignore` in a subdirectory is not read.
- **What it leaves out:** the **content** pass only. A matched file gives no symbols and no import

edges, but it stays in the file census (`files`, `subtree_files` and each language's `files`), so the directory map still counts it and a file anchor on it still resolves.

- **When it applies:** to the index built for the first commit that contains it. Commit the file to

the attached branch; until that index is ready, queries answer from the previous one.

This is the file OPVS uses for its own repository. It leaves out two vendored trees and every test tree:

```gitignore
# Vendored and archived code: other products' source, not OPVS's own.
docs/archive/
apps/spideragent/packages/openclaw/

# Every test tree: directories at any depth, and test-named files.
tests/
__tests__/
test/
*.test.*
*.spec.*
test_*.py
*_test.py
*_test.go
```

If your index reports `index_truncated: true`, ask for a smaller walk before you ask for a higher limit: list what is not your own code here, and the same budget is spent on the code that is.

**Another workspace's project** answers exactly as a project with no index does: `indexed: false`, or an empty job list. The API never confirms that someone else's project exists.

## `GET /api/v1/code/{project_id}/context`

Ask whether something already exists in the project's code. It matches two ways at once: on symbol **names** by similarity, so `resolveInvoice` finds `resolve_invoice`, and on the first line of each symbol's **doc comment** by keyword, so `"refund a payment"` finds a function whose doc says so.

Parameter

Type

Default

Notes

`query`

string

required

The name or phrase to look for, 1 to 512 characters.

`kind`

string

none

Only this kind of symbol: `function`, `method`, `class`, `interface`, `type`, `enum`, `struct` or `const`.

`branch`

string

the most recently indexed branch

Answer from this branch's index.

`limit`

integer

`10`

The most matches to return, 1 or more. A value above 50 is lowered to 50.

`format`

string

JSON

`yaml` or `md`.

```bash
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/context?query=resolve_invoice&limit=5" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"indexed": true, "verdict": "exists", "match_count": 1, "truncated": false}
# → 200 {"indexed": false, "verdict": "none", "match_count": 0} when the project has no ready index yet
# → 403 when the token does not carry code:read
# → 401 when the token is missing, expired or revoked
```

Response:

```json
{
  "project_id": "5de4fb58-fda9-4c19-bbdb-5aa43874854e",
  "query": "resolve_invoice",
  "kind": null,
  "indexed": true,
  "verdict": "exists",
  "matches": [
    {
      "name": "resolve_invoice_id",
      "kind": "function",
      "file_path": "billing/invoices.py",
      "line_start": 212,
      "line_end": 240,
      "signature": "def resolve_invoice_id(ref: str) -> Optional[str]",
      "doc_first_line": "Resolve an invoice reference, number or id, to an invoice id.",
      "derivation": "parsed",
      "name_similarity": 0.8235,
      "matched_on": "both"
    }
  ],
  "match_count": 1,
  "truncated": false,
  "truncated_reason": null,
  "limit_clamped": false,
  "effective_limit": 5,
  "freshness": {
    "commit_hash": "4625c8481d0321d507b2ed26240688bbb378c8bf",
    "branch": "main",
    "indexed_at": "2026-09-28T13:39:43Z",
    "age_seconds": 759,
    "symbol_count": 8412,
    "tool_version": "0.2.66",
    "index_truncated": false,
    "index_truncated_reason": null,
    "derivation_counts": {"parsed": 5120, "regex": 3292}
  }
}
```

How to read the answer:

- **Read **`indexed`** before **`verdict`**.** `indexed: false` means there was nothing to search: no ready

index yet, or not your project. A `verdict` of `none` then says nothing about your code.

- `verdict` is `exists` when a symbol's name is at least 0.8 similar to your query, `similar`

when something weaker matched (a looser name, or a doc-line hit), and `none` when nothing did. `matched_on` says which way each match was found: `name`, `doc` or `both`.

- `truncated: true` means the matches filled `effective_limit`, so there may be more. Raise

`limit` to see them. `limit_clamped: true` only means your `limit` was lowered to 50. It does not mean anything was left out.

- `freshness` names the commit and branch the answer comes from and when they were indexed. If

`index_truncated` is `true`, part of the repository was never read, and a `none` is weaker than it looks.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id, not its name or ref.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT as `Authorization: Bearer`.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`422`

`query` is missing, `limit` is below 1, or `format` is not `yaml` or `md`.

Fix the parameter the response's `detail` names.

`503`

The index database could not be reached.

Retry with backoff.

## `GET /api/v1/code/{project_id}/summary`

The repository at a glance: its languages, entry points and test command, how many symbols of each kind it has, and the files that hold the most symbols. It is the same data the desk map is written from, returned as data so an agent can read the facts rather than a rendering of them.

Parameter

Type

Default

Notes

`branch`

string

the most recently indexed branch

Summarise this branch's index.

`format`

string

JSON

`yaml` or `md`.

```bash
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/summary?format=md" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 a Markdown summary of the indexed commit
# → 200 {"indexed": false} when the project has no ready index yet
# → 401 when the token is missing or expired
```

Response (JSON):

```json
{
  "project_id": "5de4fb58-fda9-4c19-bbdb-5aa43874854e",
  "indexed": true,
  "repo_url": "https://github.com/acme/checkout-service.git",
  "freshness": {"commit_hash": "4625c8481d0321d507b2ed26240688bbb378c8bf", "branch": "main"},
  "languages": {
    "python": {"files": 214, "lines": 38120, "symbols": 5120},
    "typescript": {"files": 188, "lines": 24410, "symbols": 3292},
    "sql": {"files": 41, "lines": 2210, "symbols": 0}
  },
  "entry_points": [{"kind": "container", "path": "docker-compose.yml", "detail": "docker-compose.yml"}],
  "test_command": "make test",
  "kind_counts": {"function": 4980, "method": 1720, "class": 610, "type": 540, "interface": 390},
  "top_files": [{"file_path": "billing/invoices.py", "symbol_count": 96}]
}
```

`freshness` carries the same fields as it does on `/context`; it is shortened here. `test_command` is `null` when the index found no test command.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`422`

`format` is not `yaml` or `md`.

Omit it for JSON, or use one of the two.

`503`

The index database could not be reached.

Retry with backoff.

## `GET /api/v1/code/{project_id}/jobs`

The project's index jobs, newest first: what was asked for, and what happened to it. Read it when an index is missing or older than you expect.

Parameter

Type

Default

Notes

`limit`

integer

`25`

1 to 100.

`offset`

integer

`0`

For the next page, add `limit` to it while `has_more` is `true`.

`format`

string

JSON

`yaml` or `md`.

```bash
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/jobs?limit=10" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"total": 32, "limit": 10, "offset": 0, "has_more": true, "precondition": null}
# → 422 when limit is above 100
# → 401 when the token is missing, expired or revoked
```

Response:

```json
{
  "project_id": "5de4fb58-fda9-4c19-bbdb-5aa43874854e",
  "jobs": [
    {
      "job_id": "eb049864-3b0b-4d44-a6e9-052a9090b673",
      "commit_hash": "4625c8481d0321d507b2ed26240688bbb378c8bf",
      "branch": "main",
      "commit_time": "2026-09-28T13:33:07Z",
      "source": "webhook",
      "status": "done",
      "attempts": 1,
      "reason_class": null,
      "detail": null,
      "index_id": "3115f041-da29-407f-af67-6ced02fee166",
      "noop": false,
      "notified": false,
      "created_at": "2026-09-28T13:32:53Z",
      "started_at": "2026-09-28T13:37:52Z",
      "finished_at": "2026-09-28T13:39:46Z"
    }
  ],
  "total": 32,
  "limit": 10,
  "offset": 0,
  "has_more": true,
  "precondition": null
}
```

- `status` is `queued`, `running`, `done`, `skipped` or `failed`.
- `source` is `webhook` (a merge), `poll` (the five-minute check) or `manual`.
- `reason_class`** and **`detail` say why a job was skipped or failed.
- `precondition` is set when no job can be queued at all, because nothing exists yet to index.

It carries the same `reason_class` and `detail`.

The reasons you can act on:

`reason_class`

Means

What to do

`no_branch`

The attached repository names no branch.

Attach it again with a branch.

`unsupported_host`

The repository is not on GitHub.

Only GitHub repositories are indexed.

`no_credential`

The project's GitHub credential no longer works for this repository.

Attach a working credential to the project's repository.

`repo_unreachable`

GitHub refused the request: access was revoked, or the repository or branch was deleted.

Restore access or re-attach.

`retries_exhausted`

A temporary fault lasted through three attempts.

Nothing. It is asked for again automatically.

`superseded_by_newer`

A newer commit has its own job.

Nothing. The newer commit's index replaces this one.

A job that failed, or was skipped for a reason you can fix, is asked for again at most once an hour for the same commit, so fixing access is enough and you do not need to push again. A failed index also posts one in-app notification, **Repo index failed**. A run of failures posts it once, and it is never emailed. `notified` says whether a job was the one that posted it.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`422`

`limit` is outside 1 to 100, `offset` is negative, or `format` is not `yaml` or `md`.

Fix the parameter the response's `detail` names.

`503`

The index database could not be reached.

Retry with backoff.

## Stable node ids

Every file, directory and symbol in the attached repository has a stable UUIDv5 id. It is computed from the project, the canonical repository key, the node kind, the path, and the scanner's qualified name — the recipe has one spelling and a single normaliser for the repository URL, so a respelling of the same repo (`.git`, SSH, host case) keeps the same id.

`node_kind` is one of `file`, `dir` or `symbol`, and is part of the id's key: a path that was a file and later became a directory gets two ids. The scanner's qualified name (`Class.method`, Go `Recv.Method`) is stored as-is on the symbol; the recipe does not normalise it.

The repo root is the directory `.`; its anchor is `./`. The canvas's "`<dir>/ · own files`" summary cells are not directories and so carry no `node_id`.

The same id appears in every read:

Route

Field

`GET /api/v1/code/{project_id}/tree`

`directories[].node_id`

`GET /api/v1/code/{project_id}/files`

`files[].node_id`

`GET /api/v1/code/{project_id}/symbols`

`symbols[].node_id`

`GET /api/v1/code/{project_id}/context`

`matches[].node_id`

`POST /api/v1/code/{project_id}/nodes/resolve`

`node_id` (one per anchor)

`GET /api/v1/code/{project_id}/nodes/changes`

`changes[].node_id`, `changes[].other_node`

`GET /api/v1/code/{project_id}/graph`

`node_id` (the requested node), `neighbours[].node_id`

## Anchor grammar

`nodes/resolve` accepts a list of text anchors and returns each anchor's current ledger state. The grammar is the same one every other read uses to name a file, directory or symbol:

Anchor

Resolves to

`src/store/cart.py`

the file at that path

`src/store/cart.py#apply`

the top-level symbol of that name in that file

`src/services/order/`

the directory at that path (a trailing `/` selects a directory)

`./`

the repository root directory (`.`)

`path` is repo-relative. `path#symbol` carries no kind: the part after `#` is exactly what the scanner stores as the symbol's qualified name.

## Resolve, history and graph: the routes

All three routes require the `code:read` scope. A project with no attached repository and a project belonging to another brand receive the same 404. Once attached, a project without a branch is a real project state: `nodes/changes` explains it in `reason` instead of returning an empty result.

### `POST /api/v1/code/{project_id}/nodes/resolve`

Resolve up to 1,000 anchors to their current state in one call.

```bash
curl --request POST \
  --url "https://api.opvs.ai/api/v1/code/${PROJECT_ID}/nodes/resolve" \
  --header "Authorization: Bearer ${OPVS_TOKEN}" \
  --header "Content-Type: application/json" \
  --header "Accept: application/json" \
  --data '{
    "anchors": [
      "src/store/cart.py",
      "src/store/cart.py#apply",
      "./",
      "src/services/order.ts#Order.create"
    ]
  }'
# → 200 one result per anchor, in the order sent
# → 404 {"detail": "project not found"} when the project has no attached repository
```

Example response:

```json
[
  {
    "anchor": "src/store/cart.py",
    "node_id": "a6c34f5e-7d1e-5c12-b0b1-2c6c7d8e9f01",
    "state": "live"
  },
  {
    "anchor": "src/store/cart.py#apply",
    "node_id": "c9f0e9d2-3a8e-5b7f-bb41-1d9e0a2b3c45",
    "state": "live"
  },
  {
    "anchor": "./",
    "node_id": "8b1f6e4d-2c8a-5d4e-9a7b-3f0c1d2e3a4b",
    "state": "live"
  },
  {
    "anchor": "src/services/order.ts#Order.create",
    "node_id": "1a2b3c4d-5e6f-5789-a0b1-c2d3e4f50617",
    "state": "live",
    "fallback": "file"
  }
]
```

The TS class-member fallback: the scanner parses top-level declarations only, so a `path.ts#Class.member` anchor in a `.ts` or `.tsx` file whose name contains a `.` does not resolve to a member. The route falls back to the containing file and reports `fallback: "file"` rather than claiming the member resolved. The fallback is omitted for any other anchor.

Response fields:

Field

Meaning

`anchor`

The text anchor as submitted.

`node_id`

The id the anchor resolves to.

`state`

One of `live`, `removed`, `moved`, `unknown`.

`moved_to_anchor`

Set when `state` is `moved`. The anchor the node lives at now.

`removed_commit`

Set when `state` is `removed`. The commit that removed the node.

`fallback`

Set to `"file"` when a TS class-member anchor fell back to its file; absent otherwise.

Add `format=yaml` or `format=md` for plain-text renderings; omit `format` for JSON.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`404`

`project not found`: the project has no attached repository, or belongs to another workspace.

Attach a repository to the project; the two cases answer alike on purpose.

`422`

More than 1,000 anchors, a body field other than `anchors`, or an anchor the grammar cannot read.

Send at most 1,000 anchors in the grammar above; `detail` names the problem.

`503`

The index database could not be reached.

Retry with backoff.

### `GET /api/v1/code/{project_id}/nodes/changes`

The attached branch's stable-node events, oldest first by default. Pass `order=desc` for newest first, which is what the dashboard's Graph history reads.

```bash
curl --request GET \
  --url "https://api.opvs.ai/api/v1/code/${PROJECT_ID}/nodes/changes?after=0&limit=50" \
  --header "Authorization: Bearer ${OPVS_TOKEN}" \
  --header "Accept: application/json"
# → 200 the first 50 events after id 0, oldest first, and next_after
# → 404 {"detail": "index not found"} when the project has no attached repository
```

Filter to one subtree with `path_prefix`:

```bash
curl --request GET \
  --url "https://api.opvs.ai/api/v1/code/${PROJECT_ID}/nodes/changes?path_prefix=src/store&limit=50" \
  --header "Authorization: Bearer ${OPVS_TOKEN}" \
  --header "Accept: application/json"
```

Example response:

```json
{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "branch": "main",
  "changes": [
    {
      "id": 1,
      "event": "baseline",
      "node_id": null,
      "path": null,
      "node_kind": null,
      "other_node": null,
      "qname": null,
      "other_path": null,
      "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
      "at": "2026-09-29T14:00:00Z"
    },
    {
      "id": 2,
      "event": "born",
      "node_id": "a6c34f5e-7d1e-5c12-b0b1-2c6c7d8e9f01",
      "path": "src/store/cart.py",
      "node_kind": "file",
      "other_node": null,
      "qname": null,
      "other_path": null,
      "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
      "at": "2026-09-29T14:05:11Z"
    },
    {
      "id": 3,
      "event": "born",
      "node_id": "e1f2a3b4-6d7e-5f8a-9b0c-1d2e3f4a5b6c",
      "path": "src/store/cart.py",
      "node_kind": "symbol",
      "other_node": null,
      "qname": "Cart.apply",
      "other_path": null,
      "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
      "at": "2026-09-29T14:05:11Z"
    },
    {
      "id": 4,
      "event": "removed",
      "node_id": "2c3d4e5f-6071-5b9c-8dbd-cfd0e1f20314",
      "path": "src/legacy/old_module.py",
      "node_kind": "file",
      "other_node": null,
      "qname": null,
      "other_path": null,
      "commit_hash": "f0a8b2c4d6e1f2a3b4c5d6e7f8091a2b3c4d5e6f",
      "at": "2026-09-29T14:08:42Z"
    },
    {
      "id": 5,
      "event": "moved",
      "node_id": "7d8e9f01-2c6c-5d1e-8c12-b0b12c6c7d8e",
      "path": "src/services/old_name.py",
      "node_kind": "file",
      "other_node": "9f012c6c-7d1e-5c12-b0b1-2c6c7d8e9f01",
      "qname": null,
      "other_path": "src/services/new_name.py",
      "commit_hash": "7e8f9012a3b4c5d6e7f8091a2b3c4d5e6f708192",
      "at": "2026-09-29T14:12:03Z"
    },
    {
      "id": 6,
      "event": "revived",
      "node_id": "2c3d4e5f-6071-5b9c-8dbd-cfd0e1f20314",
      "path": "src/legacy/old_module.py",
      "node_kind": "file",
      "other_node": null,
      "qname": null,
      "other_path": null,
      "commit_hash": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5",
      "at": "2026-09-29T14:14:55Z"
    }
  ],
  "next_after": 6,
  "truncated": false,
  "limit_clamped": false,
  "effective_limit": 50,
  "history_applied_at": "2026-09-29T14:14:55Z",
  "history_skipped_reason": null,
  "reason": null
}
```

Query parameters:

Parameter

Meaning

`order`

`asc` (default, oldest first) or `desc` (newest first).

`after`

Return events after this event id. Default `0`. Only with `order=asc`.

`before`

Return events before this event id. Only with `order=desc`; omit it for the newest page.

`limit`

Max events to return. Server max 500; values above are clamped and `limit_clamped` reports it.

`node_id`

A stable node UUID. Repeat to select up to 100 nodes.

`path_prefix`

A repo-relative path. Matches the path itself and everything beneath it (`path_prefix=src` matches `src` and `src/...`, not `srcx`).

Response fields:

Field

Meaning

`project_id`

The requested project.

`branch`

The branch whose ledger was returned. `null` when no branch is attached — `reason` then says so.

`changes`

The events, in the requested `order`. The first row of a new ledger is a `baseline` with `node_id`, `path`, `node_kind`, `qname`, `other_node` and `other_path` all `null`.

`next_after`

The id of the last event returned, in either order. Pass it as `after` on the next `asc` call, or as `before` on the next `desc` call. When `changes` is empty, it echoes the caller's cursor.

`truncated`

More events exist after this page. A fact about the page, not the ledger.

`limit_clamped`

Whether the caller's `limit` was lowered to the server ceiling.

`effective_limit`

The limit actually applied.

`history_applied_at`

When the ready index's ledger was last advanced.

`history_skipped_reason`

Why the ready index recorded no ledger advance, when applicable. The string `ledger error (<Class>)` indicates a write that the store refused.

`reason`

Why history is unavailable, when no branch is attached. Distinct from `history_skipped_reason`.

Events:

Event

Meaning

`baseline`

The first row of a fresh history ledger. It carries the index's real commit hash.

`born`

A new node appeared on this commit. `node_id`, `path`, `node_kind` are set; `qname` is set on symbol rows. A move does **not** raise `born` for the destination: the new node is created alongside the old one's `moved`.

`removed`

A node disappeared on this commit. `path` is the last known path.

`moved`

A move makes a new node at the new path. The old node records this `moved` event with `other_node` set to the new node's id and `other_path` set to the new node's path; the new node gets no `born`.

`revived`

A previously `removed` node returned: the path came back and the node id is the same one as before.

**Newest first: **`order=desc`** and **`before`**.** Read the newest 20 events, then walk back one page by passing the response's `next_after` as `before`:

```bash
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/nodes/changes?order=desc&limit=20" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"changes": [{"id": 1304, "event": "born"}, {"id": 1303, "event": "moved"}], "next_after": 1285, "truncated": true}
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/nodes/changes?order=desc&before=1285&limit=20" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 the next 20 events, ids 1284 down to 1265
# → 422 {"detail": "after is only valid with order=asc"} when the cursor does not match the order
# → 404 {"detail": "index not found"} when the project has no attached repository
```

The cursor belongs to the order: `before` pages back through `desc`, `after` pages forward through `asc`. `truncated: true` means older (or, for `asc`, newer) events exist beyond this page.

Add `format=yaml` or `format=md` for plain-text renderings; omit `format` for JSON.

**Errors**

Error

When

Resolution

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`404`

`index not found`: the project has no attached repository, belongs to another workspace, or `project_id` is not a UUID.

Pass the project's id, and attach a repository to it; these cases answer alike on purpose.

`422`

`after` with `order=desc`, `before` with `order=asc`, more than 100 `node_id` values, or a bad `limit` or `format`.

Use the cursor that matches the order, and fix the parameter `detail` names.

`503`

The index database could not be reached.

Retry with backoff.

### `GET /api/v1/code/{project_id}/graph`

The neighbours of one node in the attached branch's import graph.

```bash
curl --request GET \
  --url "https://api.opvs.ai/api/v1/code/${PROJECT_ID}/graph?node_id=${NODE_ID}&direction=both&depth=1&limit=100" \
  --header "Authorization: Bearer ${OPVS_TOKEN}" \
  --header "Accept: application/json"
# → 200 the focus node and its direct neighbours, each with hop 1
# → 404 {"detail": "index not found"} for an unknown node or a project with no ready index
```

Query parameters:

Parameter

Meaning

`node_id`

The node to expand. **Required.**

`direction`

One of `in` (what imports this node), `out` (what this node imports), or `both`.

`depth`

`1` or `2`. Any other value is a 422.

`limit`

Max neighbours to return. Defaults to 100; values above are clamped to 500 and `limit_clamped` reports it.

`branch`

The branch whose ready index answers. Defaults to the attached branch.

Response fields:

Field

Meaning

`project_id`

The requested project.

`node_id`

The requested root node.

`focus`

The root node itself: `node_id`, `path`, `node_kind` and `state`. It is present even when `neighbours` is empty, so a client can draw a file that imports nothing and that nothing imports.

`commit_hash`

The commit of the ready index that answered.

`branch`

The branch that was used. Defaults to the attached branch.

`direction`

The direction actually applied.

`depth`

The depth actually applied.

`neighbours`

The neighbours reached at the requested depth. Each carries `node_id`, `path`, `node_kind`, `state`, `kind`, `derivation` and `hop`.

`truncated`

Whether neighbours may have been dropped (a fact about the page, not the graph).

`limit_clamped`

Whether the caller's `limit` was lowered to the server ceiling.

`effective_limit`

The limit actually applied.

Each neighbour carries:

Field

Meaning

`node_id`

The neighbour's stable node id.

`path`

The neighbour's repo-relative path.

`node_kind`

One of `file`, `dir`, `symbol`.

`state`

One of `live`, `removed`, `moved`.

`kind`

The edge kind, always `import`. Calls and symbol imports are not returned here; see [Calls and symbol imports](#calls-and-symbol-imports).

`derivation`

How the edge was derived: `parsed` (Python `ast`) or `regex` (TS/JS). The scanner emits no import edges for Go today.

`hop`

`1` for a direct neighbour, `2` for one reached through a direct neighbour (only with `depth=2`).

A missing project, a project belonging to another brand, an unknown node, or a value that is not a UUID all answer the same 404 (`index not found`).

Example response for `direction=both&depth=2`. A `hop: 2` neighbour is one edge beyond a `hop: 1` neighbour, so the path from `focus` to it is two import edges:

```json
{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "node_id": "1a2b3c4d-5e6f-5789-a0b1-c2d3e4f50617",
  "focus": {
    "node_id": "1a2b3c4d-5e6f-5789-a0b1-c2d3e4f50617",
    "path": "src/payments/charge.py",
    "node_kind": "file",
    "state": "live"
  },
  "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
  "branch": "main",
  "direction": "both",
  "depth": 2,
  "neighbours": [
    {
      "node_id": "5e6f7a8b-9c0d-5e1f-a2b3-c4d5e6f7a8b9",
      "path": "src/payments/providers/__init__.py",
      "node_kind": "file",
      "state": "live",
      "kind": "import",
      "derivation": "parsed",
      "hop": 1
    },
    {
      "node_id": "9a8b7c6d-5e4f-5302-a1b2-c3d4e5f60718",
      "path": "src/payments/integrations/stripe_client.py",
      "node_kind": "file",
      "state": "live",
      "kind": "import",
      "derivation": "parsed",
      "hop": 2
    }
  ],
  "truncated": false,
  "limit_clamped": false,
  "effective_limit": 100
}
```

**Errors**

Error

When

Resolution

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`404`

`index not found`: no attached repository, another workspace's project, no ready index, an unknown node, or an id that is not a UUID.

Resolve the node with `nodes/resolve` first; check `/tree` for `indexed`.

`422`

`depth` is not `1` or `2`, `direction` is not `in`, `out` or `both`, or `format` is unknown.

Fix the parameter `detail` names.

`503`

The index database could not be reached.

Retry with backoff.

## `GET /api/v1/code/{project_id}/graph/folders`

The read behind the Graph overview. It rolls one directory's imports up to folders, ranks the files inside it by how often they are imported and how much they import, and counts the pairs of files that import each other.

It answers three questions: **where are imports concentrated inside one directory, which files are the hubs, and which two files pull on each other.** Edges are imports only: Python (read with a full parser) and TypeScript or JavaScript (matched by pattern). There are no call edges and no Go import edges today, so a directory that holds only Go has every count at zero.

Parameter

Type

Default

Notes

`dir`

string

`.`

Repo-relative directory to roll up; `.` is the repository root. At most 1,024 characters.

`depth`

integer

`2`

Folder levels below `dir`, `1` to `10`. Deeper files fold into their folder at this depth.

`limit`

integer

`250`

The most folder rows and cross-folder link rows to return. A value above 500 is lowered to 500, and `limit_clamped` says so.

`branch`

string

the attached branch

Answer from this branch's ready index.

`format`

string

JSON

`yaml` or `md`.

```bash
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/graph/folders?dir=src%2Fpayments&depth=2&limit=100" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"indexed": true, "dir": "src/payments", "mutual_import_pairs": 2, "truncated": false, "effective_limit": 100}
# → 200 {"indexed": false, "dir": "src/payments", "depth": 2} when the project has no ready index yet
# → 422 when dir is not repo-relative, for example /etc or ../other
# → 401 when the token is missing or expired
```

Response:

```json
{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "indexed": true,
  "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
  "branch": "main",
  "dir": "src/payments",
  "depth": 2,
  "folders": [
    {
      "path": "src/payments",
      "node_id": "7a1c2d3e-4f50-5a61-b7c8-d9e0f1a2b3c4",
      "files_on_edges": 8,
      "imports_inside": 14,
      "parsed": 12,
      "regex": 2
    },
    {
      "path": "src/payments/providers",
      "node_id": "5e6f7a8b-9c0d-5e1f-a2b3-c4d5e6f7a8b9",
      "files_on_edges": 5,
      "imports_inside": 7,
      "parsed": 6,
      "regex": 1
    }
  ],
  "links": [
    {"src": "src/payments", "dst": "src/payments/providers", "imports": 7, "parsed": 6}
  ],
  "hubs": {
    "in_degree": [
      {"path": "src/payments/charge.py", "node_id": "1a2b3c4d-5e6f-5789-a0b1-c2d3e4f50617", "imports": 9}
    ],
    "out_degree": [
      {"path": "src/payments/integrations/stripe_client.py", "node_id": "9a8b7c6d-5e4f-5302-a1b2-c3d4e5f60718", "imports": 6}
    ]
  },
  "mutual_import_pairs": 2,
  "truncated": false,
  "truncated_reason": null,
  "limit_clamped": false,
  "effective_limit": 100
}
```

Field

Meaning

`indexed`

Whether a ready index answered. `false` keeps the same shape with empty lists.

`commit_hash`, `branch`

The ready index that answered.

`dir`, `depth`

The directory key and depth actually used.

`folders`

One row per folder: `path`, `node_id`, `files_on_edges` (files with at least one import edge), `imports_inside` (edges within it), and that total split into `parsed` and `regex`.

`links`

Imports between two folders, one row per ordered pair: `src`, `dst`, `imports`, `parsed`.

`hubs`

`in_degree` ranks the files most imported by others in the roll; `out_degree` ranks the files that import the most. Each row is `path`, `node_id`, `imports`.

`mutual_import_pairs`

Pairs of files where A imports B and B imports A, both inside the roll.

`truncated`, `truncated_reason`

Whether the answer was cut, and why: the index stopped early, a row bound was hit, or some edges could not be placed.

`limit_clamped`, `effective_limit`

Whether `limit` was lowered, and the limit applied.

A project with no ready index, a project of another workspace and a project id that does not exist all answer `200` with `indexed: false`, exactly as the Code view's routes do, so the route never confirms that someone else's project exists.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`422`

`dir` is absolute or climbs out of the repository, `depth` is outside 1 to 10, `limit` is below 1, or `format` is unknown.

Fix the parameter `detail` names.

`503`

The index database could not be reached.

Retry with backoff.

## `POST /api/v1/code/{project_id}/tests-for`

Which test files can a change to these files break? Send up to 200 repo-relative file paths, and the route walks the stored graph backwards from each one, through `import` edges and `reads` edges, until it reaches files that are tests. It answers from the ready index of the attached branch, so it needs no checkout and runs nothing.

A file counts as a test when its path is a test path: a `test/`, `tests/` or `__tests__/` directory at any depth, or a file named `test_*.py`, `*_test.py`, `*_test.go` or `*.test.*` / `*.spec.*` for TypeScript and JavaScript. A test is where a walk stops: the route does not keep walking from a test to whatever imports it.

The two edge kinds:

- `import`: one file imports another (Python, read with a full parser; TypeScript and JavaScript,

matched by pattern).

- `reads`: a test file names another repository file by its literal path, for example a fixture it

opens at run time.

The answer is a selection to run first, not proof of coverage. A test that reaches a file through neither edge kind (a dynamic import, a path built at run time, a Go package) is not listed.

**Request body**

Field

Type

Default

Notes

`paths`

string[]

required

1 to 200 repo-relative file paths, each at most 1,024 characters. Answers come back in this order, duplicates included.

`depth`

integer

`4`

The most edges to walk from a file, `1` to `6`.

`limit`

integer

`200`

The most tests returned per path, `1` to `500`. `tests_total` still counts all of them.

**Query parameter:** `format`, `yaml` or `md`. JSON is the default.

```bash
curl -s -X POST "https://api.opvs.ai/api/v1/code/$PROJECT_ID/tests-for" \
  -H "Authorization: Bearer $OPVS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"paths": ["src/payments/charge.py", "src/payments/legacy.py"], "depth": 4, "limit": 50}'
# → 200 {"results": [{"path": "src/payments/charge.py", "known": true, "tests_total": 3, "truncated": false}, {"path": "src/payments/legacy.py", "known": false}], "degraded": []}
# → 200 every path with known: false and degraded: ["no ready code index: tests cannot be selected"] when the project has no ready index yet
# → 404 {"detail": "project not found"} when the project has no attached repository
# → 422 when a path is absolute or climbs out of the repository, for example /etc/passwd
# → 401 when the token is missing or expired
```

Response:

```json
{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "index": {
    "index_id": "3f1e2d4c-5b6a-4798-8a1b-2c3d4e5f6071",
    "commit": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
    "indexed_at": "2026-10-27T09:14:02.511000+00:00",
    "age_seconds": 5400
  },
  "results": [
    {
      "path": "src/payments/charge.py",
      "known": true,
      "tests": [
        {"path": "tests/payments/test_charge.py", "hops": 1, "edge_kinds": ["import"]},
        {"path": "tests/fixtures/test_charge_amounts.py", "hops": 1, "edge_kinds": ["reads"]},
        {"path": "tests/api/test_checkout.py", "hops": 2, "edge_kinds": ["import", "import"]}
      ],
      "tests_total": 3,
      "truncated": false
    },
    {
      "path": "src/payments/legacy.py",
      "known": false,
      "tests": [],
      "tests_total": 0,
      "truncated": false
    }
  ],
  "degraded": []
}
```

Field

Meaning

`index`

The ready index that answered: its id, the commit it describes, when it was built and how old it is. Absent when no index answered.

`results`

One entry per requested path, in request order.

`results[].known`

Whether the path is a file in this index. `false` means "not a file here", never "no tests".

`results[].tests`

The tests that reach the file, nearest first, then by path. Each has `path`, `hops` (edges walked) and `edge_kinds`, listed from the test towards the requested file.

`results[].tests_total`, `results[].truncated`

How many tests reach the file, and whether `limit` cut `tests`.

`degraded`

Why this answer may be incomplete, in plain words. Empty when the index answered in full.

**Read **`degraded`** before you trust an empty list.** The route fails open: when it cannot answer, it still returns `200`, with every path `known: false` and the reason in `degraded`, so a client never reads "no tests" from an answer that was never measured. The reasons are:

`degraded` reason

When

What to do

`no ready code index: tests cannot be selected`

The attached branch has no ready index yet.

Run your full suite; check `GET /tree` for the index job.

`code index unavailable: tests cannot be selected`

The index database could not be reached.

Run your full suite and retry later.

`index truncated: …`

The index stopped early, so some files have no edges.

Treat the selection as partial.

`no reads edges in this index: tests that read files at run time are not listed`

The index was built before `reads` edges existed, or no test names a file by path.

Expect only import-reached tests.

Another workspace's project and a project id that does not exist both answer `404` with `project not found`, the same answer as a project with no attached repository, so the route never confirms that someone else's project exists.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`404`

The project has no attached repository, belongs to another workspace, or does not exist.

Attach a repository to the project, or check the id.

`422`

`paths` is empty or longer than 200, a path is absolute, climbs out of the repository or is `.`, `depth` is outside 1 to 6, `limit` is outside 1 to 500, or `format` is unknown.

Fix the field `detail` names.

## `POST /api/v1/code/{project_id}/callers`

Who calls this function, and which tests sit near it? Send up to 50 stable node ids, which `POST /nodes/resolve` gives you for a path or a symbol, and the route answers from the ready index of the attached branch, one result per id, in the order sent. It needs no checkout and runs nothing.

- A **symbol** id returns the functions and methods that call it.
- A **file** id returns the callers of every symbol in the file, up to 500 symbols, and each row's

`target_symbol` names the symbol called.

- A **folder** id returns no callers. Ask about a file or a symbol inside it.

Three limits hold for every answer:

- **The index is as old as its last commit.** `index.commit` and `index.age_seconds` say what the

answer describes. Code merged since then is not in it.

- **A heuristic caller may be wrong.** `resolution: "exact"` means the target was found exactly, and

`"heuristic"` is a best guess; see [Calls and symbol imports](#calls-and-symbol-imports). Rows from other files come first, then exact before heuristic, then by path.

- **A test hint is not coverage.** `nearby_tests` lists test files that import the node's file. It

does not say that any test runs this function. To choose tests for a change, use `POST /tests-for`.

Call edges come from Python files only, so for any other language the answer is unknown, not zero. A call made outside any function has no calling symbol, and a dynamic call has no edge, so neither is listed. This route answers JSON only; it takes no `format`.

**Request body**

Field

Type

Default

Notes

`node_ids`

uuid[]

required

At most 50 stable node ids. Answers come back in this order.

`callers_limit`

integer

`20`

The most caller rows per id, `1` to `100`. `callers_total` still counts all of them.

`tests_limit`

integer

`10`

The most test hints per id, `1` to `20`.

```bash
curl -s -X POST "https://api.opvs.ai/api/v1/code/$PROJECT_ID/callers" \
  -H "Authorization: Bearer $OPVS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"node_ids": ["c9f0e9d2-3a8e-5b7f-bb41-1d9e0a2b3c45"], "callers_limit": 20, "tests_limit": 10}'
# → 200 {"calls_indexed": true, "results": [{"state": "found", "callers_known": true, "callers_total": 2, "callers_truncated": false}], "degraded": null}
# → 200 {"index": null, "calls_indexed": false} with every result state: unknown when the project has no ready index yet
# → 404 {"detail": "project not found"} when the project has no attached repository
# → 422 when node_ids holds more than 50 ids, or callers_limit is above 100
# → 401 when the token is missing or expired
```

Response:

```json
{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "index": {
    "index_id": "3f1e2d4c-5b6a-4798-8a1b-2c3d4e5f6071",
    "commit": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
    "indexed_at": "2026-10-27T09:14:02.511000+00:00",
    "age_seconds": 5400
  },
  "calls_indexed": true,
  "results": [
    {
      "node_id": "c9f0e9d2-3a8e-5b7f-bb41-1d9e0a2b3c45",
      "state": "found",
      "path": "src/payments/charge.py",
      "symbol": "charge_card",
      "callers_known": true,
      "callers_unknown_reason": null,
      "callers": [
        {"node_id": "56cec54c-0a54-5cc1-9fd7-2bf9cc4e93a2", "path": "src/api/checkout.py", "symbol": "create_order", "target_symbol": "charge_card", "resolution": "exact"},
        {"node_id": "0bc0bd6b-340a-5ae1-9a8b-53f15a194dca", "path": "src/jobs/retry.py", "symbol": "retry_failed", "target_symbol": "charge_card", "resolution": "heuristic"}
      ],
      "callers_total": 2,
      "external_callers_total": null,
      "external_edges_total": null,
      "internal_edges_total": null,
      "callers_truncated": false,
      "nearby_tests": [
        {"path": "tests/payments/test_charge.py", "why": "imports_file", "heuristic": true}
      ],
      "nearby_tests_truncated": false
    }
  ],
  "degraded": null
}
```

Field

Meaning

`index`

The ready index that answered: its id, the commit it describes, when it was built and how old it is. `null` when no index answered.

`calls_indexed`

Whether the index stores any call edge. `false` means every caller is unknown, not zero.

`results`

One entry per requested id, in request order.

`results[].state`

`found`, or `unknown` for an id that is not a live node of this project. Another workspace's id reads `unknown` too, so the route never confirms it exists.

`results[].callers_known`

`true` means `callers` is the stored answer, and an empty list means no caller was found. `false` means unknown, never zero.

`results[].callers_unknown_reason`

Why `callers_known` is `false`. See below.

`results[].callers`

Each row has the caller's `node_id`, `path` and `symbol`, the `target_symbol` it calls and its `resolution`.

`results[].callers_total`

Every row for this id, including calls from inside the file on a file id.

`results[].external_callers_total`

File ids only: how many distinct symbols in **other** files call this file. Read this one, not `callers_total`: functions that call each other inside a file raise `callers_total` with no outside caller at all. `null` on a symbol id or when callers are unknown.

`results[].external_edges_total`

File ids only: how many caller rows come from other files. This counts rows and `external_callers_total` counts symbols, so never compare the two.

`results[].internal_edges_total`

File ids only: how many calls stay inside the file.

`results[].callers_truncated`

`true` when `callers_total` is above `callers_limit`, or a file has more than 500 symbols. It covers the whole list, so on a file id it can be `true` when every outside caller is shown: outside rows sort first, so they all fit when `external_edges_total` is within `callers_limit`.

`results[].nearby_tests`

Test hints, never coverage. Each has `path`, `why` (`imports_file`) and `heuristic` (always `true`).

`results[].nearby_tests_truncated`

Whether `tests_limit` cut `nearby_tests`.

`degraded`

`code_index_unavailable` when the index could not be read. The route still answers `200`, with `index` `null` and every result `state: "unknown"`, so a client never reads "no callers" from an answer that was never measured. `null` otherwise.

**Read **`callers_known`** before you trust an empty list.** The reasons are:

`callers_unknown_reason`

When

What to do

`directory`

The id is a folder.

Ask about a file or a symbol inside it.

`extension_not_indexed`

The index holds no call edges for this file type, for example TypeScript or YAML.

Search the repository for the name.

`ignored_tree`

The file sits in a folder the index lists but does not read, for example a path in `.opvsindexignore`.

Search the repository for the name.

`no_symbols`

The file declares nothing, for example an empty `__init__.py`.

There is nothing here to call. Ask about the file that holds the code.

Another workspace's project and a project id that does not exist both answer `404` with `project not found`, the same answer as a project with no attached repository.

**Errors**

Error

When

Resolution

`400`

`project_id` is not a UUID.

Pass the project's id.

`401`

The token is missing, malformed, expired or revoked.

Send a valid PAT.

`403`

The token does not carry `code:read`.

Request a token that includes `code:read`.

`404`

The project has no attached repository, belongs to another workspace, or does not exist.

Attach a repository to the project, or check the id.

`422`

`node_ids` is longer than 50 or holds a value that is not a UUID, `callers_limit` is outside 1 to 100, `tests_limit` is outside 1 to 20, or the body has an unknown field.

Fix the field `detail` names.

## Calls and symbol imports

Besides file imports, the index stores two finer kinds of edge between code nodes:

Edge kind

From → to

Languages

`derivation`

`symbol_import`

a file → the function or class it imports by name (`from billing.money import round_half_even`, `import { roundHalfEven } from "./money"`)

Python, TypeScript, JavaScript

`parsed` for Python, `regex` for TS/JS

`call`

a function or method → the function or method it calls

Python

`parsed`

Every one of these edges carries a `resolution`:

- `resolved`: the target was found exactly, as a function in the same file, a name the

file imports, a `self.` method of the same class, or an attribute of an imported module.

- `heuristic`: the call names a function that is not defined or imported by name in the

file, and exactly one module the file imports defines a function of that name. The edge points there. It is a best guess, never a choice between two candidates: a call that could mean two targets gets no edge at all.

A call whose target cannot be found in the repository (a library call, a dynamic call) gets no edge.

**Where to read them.** `POST /callers` above reads the `call` edges into a node. They are also walked by `POST /api/v1/memory/entries:near` with `kinds` set to `call` (calls) or `import` (file imports and symbol imports together), to find the lessons near a piece of code. `GET /graph` above is unchanged and still returns file imports only.

**Caps.** Calls and symbol imports are best-effort extras, bounded on their own: at most 500 call edges from any one file, and at most 50,000 call and symbol-import edges per index. When a cap drops edges, or a call has no findable target, `GET /tree` says so in `semantic_edges_cap_reason`, for example:

```json
{
  "semantic_edges_cap_reason": "per-file call-edge cap dropped 40 edges (cap 500); unresolved calls dropped 12"
}
```

That reason never sets the index's `truncated` flag: file and symbol coverage is unaffected, and the file-import graph keeps its own, separate limit.

## Explore the index in the dashboard: the Code view

The Code view turns a project's structural code index into a directory map. It shows where code lives, how much of each directory was scanned, and whether symbols came from a full parser or a pattern-based pass. Its **Map** lens shows no dependencies. Its **Graph** lens draws import edges: the overview reads `graph/folders`, a single file's focus reads `graph`, and its history reads `nodes/changes?order=desc`. Neither lens shows call relationships.

### Open the Code view

1. Open a project in the dashboard.
2. Open the project canvas.
3. Select **Code**.

The view opens on the latest ready index for the attached repository and branch. Select a directory to list files that contain symbols. Select a file to see its symbols in source order.

The map uses the complete file census for tile size. Its coverage shading compares files scanned with files found. Symbol counts and line counts can be lower bounds when a scan stops early.

### Understand the states

The view keeps several states separate because they mean different things:

- **No ready index:** `indexed` is `false`. The attached repository is identified by `repo_url` and

`repo_branch` when available.

- **Index job queued or running:** the `job` block reports the job state. A queued job with

`waiting_for_machine: true` is ready to run but has not been claimed by an indexing machine.

- **Index job skipped or failed:** `job.reason_class` and `job.detail` explain what happened. A

skipped job is a recorded outcome, not a missing status.

- **Index attempt pending, failed, or abandoned:** the `attempt` block describes the newest index

run that has not produced a ready index. `abandoned` means a pending run stopped updating.

- **Ready index:** `indexed` is `true`. The `freshness` block identifies the commit and branch.

A newer job or attempt can appear beside the current map while it is being refreshed.

Render an unknown `job.status` as a neutral state. Do not treat it as a failure.

### Understand partial and coarse results

Three flags describe three different cuts:

- `freshness.index_truncated` means the index is partial. Use `freshness.scan_truncated` to tell

whether the symbol walk stopped early. `freshness.symbols_dropped_at_ingest` counts symbols that were read but could not be stored. If `freshness.enumeration_incomplete` is `true`, directories may also be missing from the census.

- `directories_capped` means the stored tree is coarser than the directory rollup produced by the

scan. Deeper directories are normally folded into stored ancestors, so their `subtree_*` totals remain useful. Read `directories_cap_reason` for the exact exception or cap description.

- `truncated` means this request's `depth` parameter omitted stored directories. It does not mean

the scan stopped early. `truncated_reason` says what the request left out.

For each directory, size by `subtree_files`. Shade coverage by `subtree_files_scanned / subtree_files`. Do not size a tile by symbol counts or scanned line counts.

### API routes used by the canvas

All four routes require `code:read`. A project with no ready index returns `indexed: false` rather than a missing-project response. This also prevents one brand from using the route to discover another brand's project.

Route

Purpose

Query parameters

Format

`GET /api/v1/code/{project_id}/tree`

Directory map and index state

`branch`, `depth`, `format`

JSON, YAML, or Markdown

`GET /api/v1/code/{project_id}/fidelity`

Parsed and pattern-pass symbol counts by directory

`branch`

JSON

`GET /api/v1/code/{project_id}/files`

Files with symbols beneath a directory

`directory`, `direct`, `branch`, `limit`, `order`, `after`

JSON, YAML, or Markdown

`GET /api/v1/code/{project_id}/symbols`

Symbols in one file, in source order

`file_path`, `branch`, `limit`

JSON

`depth` is 1-based and can only narrow the stored tree. Use `directory=.` for the repository root. Set `direct=true` to list only files directly inside that directory. `/files` does not list files with no stored symbols. It orders files by symbol count (`order=symbols`, the default); to read every file in a large directory, pass `order=path` and page with `after` set to the previous response's `next_after` until `next_after` is `null`. `after` is refused with a 422 unless `order=path`. `/symbols` reports containment and source order, not relationships.

### Get the tree

```bash
curl --request GET \
  --url "https://api.opvs.ai/api/v1/code/${PROJECT_ID}/tree?depth=2" \
  --header "Authorization: Bearer ${OPVS_TOKEN}" \
  --header "Accept: application/json"
```

Example response:

```json
{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "indexed": true,
  "repo_url": "https://github.com/example/storefront",
  "repo_branch": "main",
  "freshness": {
    "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
    "branch": "main",
    "indexed_at": "2026-09-30T14:22:08Z",
    "age_seconds": 118,
    "symbol_count": 1842,
    "tool_version": "0.3.0",
    "index_truncated": false,
    "index_truncated_reason": null,
    "scan_truncated": false,
    "scan_truncated_reason": null,
    "enumeration_incomplete": false,
    "symbols_dropped_at_ingest": 0,
    "symbols_dropped_reason": null,
    "derivation_counts": {
      "parsed": 1680,
      "regex": 162
    },
    "edge_count": 640,
    "edge_derivation_counts": {
      "parsed": 571,
      "regex": 69
    },
    "files_on_edges": 152,
    "edges_incomplete": false
  },
  "directories": [
    {
      "path": "src",
      "files": 4,
      "lines": 820,
      "depth": 1,
      "files_scanned": 4,
      "subtree_files": 19,
      "subtree_files_scanned": 19,
      "subtree_lines": 4310,
      "synthesized": false
    }
  ],
  "directory_count": 1,
  "stored_count": 1,
  "total_count": 1,
  "max_depth": 1,
  "directories_capped": false,
  "directories_cap_reason": null,
  "truncated": false,
  "truncated_reason": null,
  "attempt": null,
  "job": null
}
```

Add `format=yaml` or `format=md` to return a plain-text rendering. Omit `format` for JSON.

### Tree response fields

The top-level response contains:

Field

Meaning

`project_id`

The requested project.

`indexed`

Whether a ready index was found.

`repo_url`

The repository represented by the response.

`repo_branch`

The attached branch when `indexed` is `false`; otherwise `null`.

`freshness`

Commit, age, completeness, and extraction fidelity for a ready index.

`directories`

Directory tiles returned at the requested depth.

`directory_count`

Tiles in this response, including synthesized ancestors.

`stored_count`

Scanner-produced directories stored before request-depth filtering.

`total_count`

Directories emitted by the scanner before the stored-tree cap.

`max_depth`

Deepest directory level in this response.

`directories_capped`

Whether the stored tree was made coarser at ingest.

`directories_cap_reason`

Why the stored tree was capped, when recorded.

`truncated`

Whether this request's `depth` omitted stored directories.

`truncated_reason`

What this request omitted.

`attempt`

The newest non-ready index attempt, if one applies.

`job`

The newest job for the attached branch, if one exists or a precondition blocks one.

Each `freshness` block contains `commit_hash`, `branch`, `indexed_at`, `age_seconds`, `symbol_count`, `tool_version`, `index_truncated`, `index_truncated_reason`, `scan_truncated`, `scan_truncated_reason`, `enumeration_incomplete`, `symbols_dropped_at_ingest`, `symbols_dropped_reason`, and `derivation_counts`.

On `/tree` only, `freshness` also carries four import-edge counts. They are totals over the whole index and name no folder or file:

Field

Meaning

`edge_count`

Import edges the index stored. `null` when the scan did not measure edges, which is not the same as a measured `0`.

`edge_derivation_counts`

`edge_count` split into `parsed` (Python) and `regex` (TypeScript and JavaScript). Both keys are present when edges were measured.

`files_on_edges`

Files that appear on at least one import edge.

`edges_incomplete`

`true` when the scan stopped early or import edges were dropped while storing the index. Every edge total is then a lower bound. Symbols dropped alone do not set it.

Each item in `directories` contains `path`, `files`, `lines`, `depth`, `files_scanned`, `subtree_files`, `subtree_files_scanned`, `subtree_lines`, and `synthesized`. The direct `files` and `lines` values describe that directory only. The `subtree_*` values include descendants.

An `attempt` contains `status`, `commit_hash`, `branch`, `error_message`, and `updated_at`. Its `status` is `pending`, `failed`, or `abandoned`.

A `job` contains `job_id`, `status`, `reason_class`, `detail`, `source`, `commit_hash`, `branch`, `commit_time`, `attempts`, `index_id`, `noop`, `not_before`, `lease_until`, `created_at`, `updated_at`, `started_at`, `finished_at`, `waiting_for_machine`, `waiting_since`, `precondition`, and `summary`. Job statuses are `queued`, `running`, `done`, `skipped`, and `failed`. A `precondition` contains `reason_class` and `detail`; its reason is `no_branch`, `unsupported_host`, or `no_credential`. Treat `summary` as display text. Key automation on the structured fields.

### Drill-down response fields

- `/fidelity` returns `project_id`, `indexed`, `commit_hash`, `branch`, `max_depth`, and

`directories`. Each directory contains `path` and `derivation_counts`.

- `/files` returns `project_id`, `indexed`, `commit_hash`, `branch`, `directory`, `files`,

`file_count`, `symbol_count`, `next_after` (the last path of this page when `order=path` and another page exists), `truncated`, and `truncated_reason`. Each file contains `file_path`, `symbol_count`, and `derivation_counts`.

- `/symbols` returns `project_id`, `indexed`, `commit_hash`, `branch`, `file_path`, `symbols`,

`symbol_count`, `truncated`, and `truncated_reason`. Each symbol contains `name`, `kind`, `line_start`, `line_end`, `signature`, `doc_first_line`, and `derivation`.

The drill-down `truncated` field means the route's result page was capped. It is separate from `freshness.index_truncated`, which describes completeness of the index itself.

## For agents

Gateway agents reach every route on this page through the signed first-party marketplace skill `@opvs-ai/code-index`. It is reached through the marketplace runtime, which resolves the brand's server-held platform credential, so no tenant credential enters the agent session.

Method

Route

`getTree`

`GET /api/v1/code/{project_id}/tree`

`getSummary`

`GET /api/v1/code/{project_id}/summary`

`listFiles`

`GET /api/v1/code/{project_id}/files`

`listSymbols`

`GET /api/v1/code/{project_id}/symbols`

`searchContext`

`GET /api/v1/code/{project_id}/context`

`resolveNodes`

`POST /api/v1/code/{project_id}/nodes/resolve`

`listNodeChanges`

`GET /api/v1/code/{project_id}/nodes/changes`

`getGraph`

`GET /api/v1/code/{project_id}/graph`

`getGraphFolders`

`GET /api/v1/code/{project_id}/graph/folders` (since 0.4.0)

`callers`

`POST /api/v1/code/{project_id}/callers` (since 0.5.0)

`testsFor`

`POST /api/v1/code/{project_id}/tests-for` (since 0.6.0)

**When OPVS cannot answer.** If a call returns a 5xx, times out or cannot connect, the skill tells the agent to carry on without the index (read the repository, use the handbook), not to retry in a loop, and to say once in its reply that the code index was unavailable. A 4xx is not this case: it is a wrong call, and the agent fixes the call.

**From an IDE.** The OPVS MCP server (`@opvs-ai/mcp` 2.12.0 or later) lists ten read-only code tools: `code_context`, `code_summary`, `code_tree`, `code_files`, `code_symbols`, `code_resolve`, `code_callers`, `code_changes`, `code_graph` and `code_graph_folders`. See [MCP server](/developers/tools/mcp-server). The CLI's `opvs code` group covers these routes and `tests-for`; see [CLI reference](/developers/tools/cli).
