Code index
When a project has a GitHub repository attached, OPVS keeps a code index of it: every function, class, method and type it can find, with its file, its line 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.mdbeside 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 secondCLAUDE.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.
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 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
queueduntil 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_filesand each language'sfiles), 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
indexedbeforeverdict.indexed: falsemeans there was nothing to search: no ready index yet, or not your project. Averdictofnonethen says nothing about your code. verdictisexistswhen a symbol's name is at least 0.8 similar to your query,similarwhen something weaker matched (a looser name, or a doc-line hit), andnonewhen nothing did.matched_onsays which way each match was found:name,docorboth.truncated: truemeans the matches filledeffective_limit, so there may be more. Raiselimitto see them.limit_clamped: trueonly means yourlimitwas 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. Ifindex_truncatedistrue, part of the repository was never read, and anoneis 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
}
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 samereason_classanddetail.
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. |
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, aself.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. These edges are 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_urlandrepo_branchwhen available. - Index job queued or running: the
jobblock reports the job state. A queued job withwaiting_for_machine: trueis 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.abandonedmeans 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_ingestcounts symbols that were read but could not be stored. Iffreshness.enumeration_incompleteistrue, 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 theirsubtree_*totals remain useful. Readdirectories_cap_reasonfor the exact exception or cap description.truncatedmeans this request'sdepthparameter omitted stored directories. It does not mean the scan stopped early.truncated_reasonsays 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¶
/fidelityreturnsproject_id,indexed,commit_hash,branch,max_depth, anddirectories. Each directory containspathandderivation_counts./filesreturnsproject_id,indexed,commit_hash,branch,directory,files,file_count,symbol_count,next_after(the last path of this page whenorder=pathand another page exists),truncated, andtruncated_reason. Each file containsfile_path,symbol_count, andderivation_counts./symbolsreturnsproject_id,indexed,commit_hash,branch,file_path,symbols,symbol_count,truncated, andtruncated_reason. Each symbol containsname,kind,line_start,line_end,signature,doc_first_line, andderivation.
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) |
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.11.0 or later) lists nine read-only code
tools, one per route on this page: code_context, code_summary, code_tree, code_files,
code_symbols, code_resolve, code_changes, code_graph and code_graph_folders. See
MCP server. The CLI's opvs code group covers the same routes; see
CLI reference.