Code index

How-toUpdated 45 min read
View as Markdown
On this page

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, which also covers a repository no machine indexes.

Base path: /api/v1/code. Authentication: a brand-scoped PAT or a dashboard session. See 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:

# 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.

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:

{
  "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.

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):

{
  "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.

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:

{
  "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.

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:

[
  {
    "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.

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:

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:

{
  "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:

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.

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.

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:

{
  "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.

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:

{
  "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.

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:

{
  "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. 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.

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:

{
  "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:

{
  "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

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:

{
  "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. The CLI's opvs code group covers these routes and tests-for; see CLI reference.

Did this answer your question?