Code index
On this page
- How the index is built
- Leave code out of the index with .opvsindexignore
- GET /api/v1/code/{project_id}/context
- GET /api/v1/code/{project_id}/summary
- GET /api/v1/code/{project_id}/jobs
- Stable node ids
- Anchor grammar
- Resolve, history and graph: the routes
- POST /api/v1/code/{project_id}/nodes/resolve
- GET /api/v1/code/{project_id}/nodes/changes
- GET /api/v1/code/{project_id}/graph
- GET /api/v1/code/{project_id}/graph/folders
- POST /api/v1/code/{project_id}/tests-for
- POST /api/v1/code/{project_id}/callers
- Calls and symbol imports
- Explore the index in the dashboard: the Code view
- Open the Code view
- Understand the states
- Understand partial and coarse results
- API routes used by the canvas
- Get the tree
- Tree response fields
- Drill-down response fields
- For agents
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
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.
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.
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.
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
.opvsindexignorein 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.goIf 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 |
|---|---|---|---|
| string | required | The name or phrase to look for, 1 to 512 characters. |
| string | none | Only this kind of symbol: |
| string | the most recently indexed branch | Answer from this branch's index. |
| integer |
| The most matches to return, 1 or more. A value above 50 is lowered to 50. |
| string | JSON |
|
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 revokedResponse:
{
"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
indexedbeforeverdict.indexed: falsemeans there was nothing to search: no ready
index yet, or not your project. A verdict of none then says nothing about your code.
verdictisexistswhen 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: truemeans the matches filledeffective_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.
freshnessnames 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 |
|---|---|---|
|
| Pass the project's id, not its name or ref. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT as |
| The token does not carry | Request a token that includes |
|
| Fix the parameter the response's |
| 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 |
|---|---|---|---|
| string | the most recently indexed branch | Summarise this branch's index. |
| string | JSON |
|
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 expiredResponse (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 |
|---|---|---|
|
| Pass the project's id. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
|
| Omit it for JSON, or use one of the two. |
| 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 |
|---|---|---|---|
| integer |
| 1 to 100. |
| integer |
| For the next page, add |
| string | JSON |
|
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 revokedResponse:
{
"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
}statusisqueued,running,done,skippedorfailed.sourceiswebhook(a merge),poll(the five-minute check) ormanual.reason_classanddetailsay why a job was skipped or failed.preconditionis 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:
| Means | What to do |
|---|---|---|
| The attached repository names no branch. | Attach it again with a branch. |
| The repository is not on GitHub. | Only GitHub repositories are indexed. |
| The project's GitHub credential no longer works for this repository. | Attach a working credential to the project's repository. |
| GitHub refused the request: access was revoked, or the repository or branch was deleted. | Restore access or re-attach. |
| A temporary fault lasted through three attempts. | Nothing. It is asked for again automatically. |
| 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 |
|---|---|---|
|
| Pass the project's id. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
|
| Fix the parameter the response's |
| 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
| the file at that path |
| the top-level symbol of that name in that file |
| the directory at that path (a trailing |
| 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 repositoryExample 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 |
|---|---|
| The text anchor as submitted. |
| The id the anchor resolves to. |
| One of |
| Set when |
| Set when |
| Set to |
Add format=yaml or format=md for plain-text renderings; omit format for JSON.
Errors
Error | When | Resolution |
|---|---|---|
|
| Pass the project's id. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
|
| Attach a repository to the project; the two cases answer alike on purpose. |
| More than 1,000 anchors, a body field other than | Send at most 1,000 anchors in the grammar above; |
| 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 repositoryFilter 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 |
|---|---|
|
|
| Return events after this event id. Default |
| Return events before this event id. Only with |
| Max events to return. Server max 500; values above are clamped and |
| A stable node UUID. Repeat to select up to 100 nodes. |
| A repo-relative path. Matches the path itself and everything beneath it ( |
Response fields:
Field | Meaning |
|---|---|
| The requested project. |
| The branch whose ledger was returned. |
| The events, in the requested |
| The id of the last event returned, in either order. Pass it as |
| More events exist after this page. A fact about the page, not the ledger. |
| Whether the caller's |
| The limit actually applied. |
| When the ready index's ledger was last advanced. |
| Why the ready index recorded no ledger advance, when applicable. The string |
| Why history is unavailable, when no branch is attached. Distinct from |
Events:
Event | Meaning |
|---|---|
| The first row of a fresh history ledger. It carries the index's real commit hash. |
| A new node appeared on this commit. |
| A node disappeared on this commit. |
| A move makes a new node at the new path. The old node records this |
| A previously |
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 repositoryThe 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 |
|---|---|---|
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
|
| Pass the project's id, and attach a repository to it; these cases answer alike on purpose. |
|
| Use the cursor that matches the order, and fix the parameter |
| 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 indexQuery parameters:
Parameter | Meaning |
|---|---|
| The node to expand. Required. |
| One of |
|
|
| Max neighbours to return. Defaults to 100; values above are clamped to 500 and |
| The branch whose ready index answers. Defaults to the attached branch. |
Response fields:
Field | Meaning |
|---|---|
| The requested project. |
| The requested root node. |
| The root node itself: |
| The commit of the ready index that answered. |
| The branch that was used. Defaults to the attached branch. |
| The direction actually applied. |
| The depth actually applied. |
| The neighbours reached at the requested depth. Each carries |
| Whether neighbours may have been dropped (a fact about the page, not the graph). |
| Whether the caller's |
| The limit actually applied. |
Each neighbour carries:
Field | Meaning |
|---|---|
| The neighbour's stable node id. |
| The neighbour's repo-relative path. |
| One of |
| One of |
| The edge kind, always |
| How the edge was derived: |
|
|
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 |
|---|---|---|
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
|
| Resolve the node with |
|
| Fix the parameter |
| 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 |
|---|---|---|---|
| string |
| Repo-relative directory to roll up; |
| integer |
| Folder levels below |
| integer |
| The most folder rows and cross-folder link rows to return. A value above 500 is lowered to 500, and |
| string | the attached branch | Answer from this branch's ready index. |
| string | JSON |
|
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 expiredResponse:
{
"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 |
|---|---|
| Whether a ready index answered. |
| The ready index that answered. |
| The directory key and depth actually used. |
| One row per folder: |
| Imports between two folders, one row per ordered pair: |
|
|
| Pairs of files where A imports B and B imports A, both inside the roll. |
| Whether the answer was cut, and why: the index stopped early, a row bound was hit, or some edges could not be placed. |
| Whether |
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 |
|---|---|---|
|
| Pass the project's id. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
|
| Fix the parameter |
| 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 |
|---|---|---|---|
| string[] | required | 1 to 200 repo-relative file paths, each at most 1,024 characters. Answers come back in this order, duplicates included. |
| integer |
| The most edges to walk from a file, |
| integer |
| The most tests returned per path, |
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 expiredResponse:
{
"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 |
|---|---|
| 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. |
| One entry per requested path, in request order. |
| Whether the path is a file in this index. |
| The tests that reach the file, nearest first, then by path. Each has |
| How many tests reach the file, and whether |
| 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:
| When | What to do |
|---|---|---|
| The attached branch has no ready index yet. | Run your full suite; check |
| The index database could not be reached. | Run your full suite and retry later. |
| The index stopped early, so some files have no edges. | Treat the selection as partial. |
| The index was built before | 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 |
|---|---|---|
|
| Pass the project's id. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
| The project has no attached repository, belongs to another workspace, or does not exist. | Attach a repository to the project, or check the id. |
|
| Fix the field |
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.commitandindex.age_secondssay 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_testslists 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 |
|---|---|---|---|
| uuid[] | required | At most 50 stable node ids. Answers come back in this order. |
| integer |
| The most caller rows per id, |
| integer |
| The most test hints per id, |
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 expiredResponse:
{
"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 |
|---|---|
| The ready index that answered: its id, the commit it describes, when it was built and how old it is. |
| Whether the index stores any call edge. |
| One entry per requested id, in request order. |
|
|
|
|
| Why |
| Each row has the caller's |
| Every row for this id, including calls from inside the file on a file id. |
| File ids only: how many distinct symbols in other files call this file. Read this one, not |
| File ids only: how many caller rows come from other files. This counts rows and |
| File ids only: how many calls stay inside the file. |
|
|
| Test hints, never coverage. Each has |
| Whether |
|
|
Read callers_known before you trust an empty list. The reasons are:
| When | What to do |
|---|---|---|
| The id is a folder. | Ask about a file or a symbol inside it. |
| The index holds no call edges for this file type, for example TypeScript or YAML. | Search the repository for the name. |
| The file sits in a folder the index lists but does not read, for example a path in | Search the repository for the name. |
| The file declares nothing, for example an empty | 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 |
|---|---|---|
|
| Pass the project's id. |
| The token is missing, malformed, expired or revoked. | Send a valid PAT. |
| The token does not carry | Request a token that includes |
| The project has no attached repository, belongs to another workspace, or does not exist. | Attach a repository to the project, or check the id. |
|
| Fix the field |
Calls and symbol imports
Besides file imports, the index stores two finer kinds of edge between code nodes:
Edge kind | From → to | Languages |
|
|---|---|---|---|
| a file → the function or class it imports by name ( | Python, TypeScript, JavaScript |
|
| a function or method → the function or method it calls | Python |
|
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
Open a project in the dashboard.
Open the project canvas.
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:
indexedisfalse. The attached repository is identified byrepo_urland
repo_branch when available.
Index job queued or running: the
jobblock 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_classandjob.detailexplain what happened. A
skipped job is a recorded outcome, not a missing status.
Index attempt pending, failed, or abandoned: the
attemptblock describes the newest index
run that has not produced a ready index. abandoned means a pending run stopped updating.
Ready index:
indexedistrue. Thefreshnessblock 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_truncatedmeans the index is partial. Usefreshness.scan_truncatedto 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_cappedmeans 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.
truncatedmeans this request'sdepthparameter 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 |
|---|---|---|---|
| Directory map and index state |
| JSON, YAML, or Markdown |
| Parsed and pattern-pass symbol counts by directory |
| JSON |
| Files with symbols beneath a directory |
| JSON, YAML, or Markdown |
| Symbols in one file, in source order |
| 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 |
|---|---|
| The requested project. |
| Whether a ready index was found. |
| The repository represented by the response. |
| The attached branch when |
| Commit, age, completeness, and extraction fidelity for a ready index. |
| Directory tiles returned at the requested depth. |
| Tiles in this response, including synthesized ancestors. |
| Scanner-produced directories stored before request-depth filtering. |
| Directories emitted by the scanner before the stored-tree cap. |
| Deepest directory level in this response. |
| Whether the stored tree was made coarser at ingest. |
| Why the stored tree was capped, when recorded. |
| Whether this request's |
| What this request omitted. |
| The newest non-ready index attempt, if one applies. |
| 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 |
|---|---|
| Import edges the index stored. |
|
|
| Files that appear on at least one import edge. |
|
|
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
/fidelityreturnsproject_id,indexed,commit_hash,branch,max_depth, and
directories. Each directory contains path and derivation_counts.
/filesreturnsproject_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.
/symbolsreturnsproject_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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.