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

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

  1. Attach a GitHub repository to a project, with a branch. A brand admin does this with PUT /api/v1/builders/projects/{project_id}/repo. The attached branch is the one that is indexed. A repository attached without a branch is not indexed.
  2. 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.
  3. 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.
  4. The new index takes over when it is ready. Until then, queries answer from the previous index, and every answer names the commit it describes.

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

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

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

Leave code out of the index with .opvsindexignore

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

  • Syntax: the same as .gitignore. One pattern per line and # starts a comment. A pattern with no / except a trailing one (tests/, *.spec.*) matches at any depth; a pattern with a / inside it (docs/archive/) is matched from the repository root. A trailing / matches directories only, and ! re-includes a path an earlier line excluded; the last matching line wins.
  • Where: the repository root only. A .opvsindexignore in a subdirectory is not read.
  • What it leaves out: the content pass only. A matched file gives no symbols and no import edges, but it stays in the file census (files, subtree_files and each language's files), so the directory map still counts it and a file anchor on it still resolves.
  • When it applies: to the index built for the first commit that contains it. Commit the file to the attached branch; until that index is ready, queries answer from the previous one.

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

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

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

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

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

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

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

Parameter Type Default Notes
query string required The name or phrase to look for, 1 to 512 characters.
kind string none Only this kind of symbol: function, method, class, interface, type, enum, struct or const.
branch string the most recently indexed branch Answer from this branch's index.
limit integer 10 The most matches to return, 1 or more. A value above 50 is lowered to 50.
format string JSON yaml or md.
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/context?query=resolve_invoice&limit=5" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"indexed": true, "verdict": "exists", "match_count": 1, "truncated": false}
# → 200 {"indexed": false, "verdict": "none", "match_count": 0} when the project has no ready index yet
# → 403 when the token does not carry code:read
# → 401 when the token is missing, expired or revoked

Response:

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

How to read the answer:

  • Read indexed before verdict. indexed: false means there was nothing to search: no ready index yet, or not your project. A verdict of none then says nothing about your code.
  • verdict is exists when a symbol's name is at least 0.8 similar to your query, similar when something weaker matched (a looser name, or a doc-line hit), and none when nothing did. matched_on says which way each match was found: name, doc or both.
  • truncated: true means the matches filled effective_limit, so there may be more. Raise limit to see them. limit_clamped: true only means your limit was lowered to 50. It does not mean anything was left out.
  • freshness names the commit and branch the answer comes from and when they were indexed. If index_truncated is true, part of the repository was never read, and a none is weaker than it looks.

Errors

Error When Resolution
400 project_id is not a UUID. Pass the project's id, not its name or ref.
401 The token is missing, malformed, expired or revoked. Send a valid PAT as Authorization: Bearer.
403 The token does not carry code:read. Request a token that includes code:read.
422 query is missing, limit is below 1, or format is not yaml or md. Fix the parameter the response's detail names.
503 The index database could not be reached. Retry with backoff.

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

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

Parameter Type Default Notes
branch string the most recently indexed branch Summarise this branch's index.
format string JSON yaml or md.
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/summary?format=md" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 a Markdown summary of the indexed commit
# → 200 {"indexed": false} when the project has no ready index yet
# → 401 when the token is missing or expired

Response (JSON):

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

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

Errors

Error When Resolution
400 project_id is not a UUID. Pass the project's id.
401 The token is missing, malformed, expired or revoked. Send a valid PAT.
403 The token does not carry code:read. Request a token that includes code:read.
422 format is not yaml or md. Omit it for JSON, or use one of the two.
503 The index database could not be reached. Retry with backoff.

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

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

Parameter Type Default Notes
limit integer 25 1 to 100.
offset integer 0 For the next page, add limit to it while has_more is true.
format string JSON yaml or md.
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/jobs?limit=10" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"total": 32, "limit": 10, "offset": 0, "has_more": true, "precondition": null}
# → 422 when limit is above 100
# → 401 when the token is missing, expired or revoked

Response:

{
  "project_id": "5de4fb58-fda9-4c19-bbdb-5aa43874854e",
  "jobs": [
    {
      "job_id": "eb049864-3b0b-4d44-a6e9-052a9090b673",
      "commit_hash": "4625c8481d0321d507b2ed26240688bbb378c8bf",
      "branch": "main",
      "commit_time": "2026-09-28T13:33:07Z",
      "source": "webhook",
      "status": "done",
      "attempts": 1,
      "reason_class": null,
      "detail": null,
      "index_id": "3115f041-da29-407f-af67-6ced02fee166",
      "noop": false,
      "notified": false,
      "created_at": "2026-09-28T13:32:53Z",
      "started_at": "2026-09-28T13:37:52Z",
      "finished_at": "2026-09-28T13:39:46Z"
    }
  ],
  "total": 32,
  "limit": 10,
  "offset": 0,
  "has_more": true,
  "precondition": null
}
  • status is queued, running, done, skipped or failed.
  • source is webhook (a merge), poll (the five-minute check) or manual.
  • reason_class and detail say why a job was skipped or failed.
  • precondition is set when no job can be queued at all, because nothing exists yet to index. It carries the same reason_class and detail.

The reasons you can act on:

reason_class Means What to do
no_branch The attached repository names no branch. Attach it again with a branch.
unsupported_host The repository is not on GitHub. Only GitHub repositories are indexed.
no_credential The project's GitHub credential no longer works for this repository. Attach a working credential to the project's repository.
repo_unreachable GitHub refused the request: access was revoked, or the repository or branch was deleted. Restore access or re-attach.
retries_exhausted A temporary fault lasted through three attempts. Nothing. It is asked for again automatically.
superseded_by_newer A newer commit has its own job. Nothing. The newer commit's index replaces this one.

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

Errors

Error When Resolution
400 project_id is not a UUID. Pass the project's id.
401 The token is missing, malformed, expired or revoked. Send a valid PAT.
403 The token does not carry code:read. Request a token that includes code:read.
422 limit is outside 1 to 100, offset is negative, or format is not yaml or md. Fix the parameter the response's detail names.
503 The index database could not be reached. Retry with backoff.

Stable node ids

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

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

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

The same id appears in every read:

Route Field
GET /api/v1/code/{project_id}/tree directories[].node_id
GET /api/v1/code/{project_id}/files files[].node_id
GET /api/v1/code/{project_id}/symbols symbols[].node_id
GET /api/v1/code/{project_id}/context matches[].node_id
POST /api/v1/code/{project_id}/nodes/resolve node_id (one per anchor)
GET /api/v1/code/{project_id}/nodes/changes changes[].node_id, changes[].other_node
GET /api/v1/code/{project_id}/graph node_id (the requested node), neighbours[].node_id

Anchor grammar

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

Anchor Resolves to
src/store/cart.py the file at that path
src/store/cart.py#apply the top-level symbol of that name in that file
src/services/order/ the directory at that path (a trailing / selects a directory)
./ the repository root directory (.)

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

Resolve, history and graph: the routes

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

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

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

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

Example response:

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

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

Response fields:

Field Meaning
anchor The text anchor as submitted.
node_id The id the anchor resolves to.
state One of live, removed, moved, unknown.
moved_to_anchor Set when state is moved. The anchor the node lives at now.
removed_commit Set when state is removed. The commit that removed the node.
fallback Set to "file" when a TS class-member anchor fell back to its file; absent otherwise.

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

Errors

Error When Resolution
400 project_id is not a UUID. Pass the project's id.
401 The token is missing, malformed, expired or revoked. Send a valid PAT.
403 The token does not carry code:read. Request a token that includes code:read.
404 project not found: the project has no attached repository, or belongs to another workspace. Attach a repository to the project; the two cases answer alike on purpose.
422 More than 1,000 anchors, a body field other than anchors, or an anchor the grammar cannot read. Send at most 1,000 anchors in the grammar above; detail names the problem.
503 The index database could not be reached. Retry with backoff.

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

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

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

Filter to one subtree with path_prefix:

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

Example response:

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

Query parameters:

Parameter Meaning
order asc (default, oldest first) or desc (newest first).
after Return events after this event id. Default 0. Only with order=asc.
before Return events before this event id. Only with order=desc; omit it for the newest page.
limit Max events to return. Server max 500; values above are clamped and limit_clamped reports it.
node_id A stable node UUID. Repeat to select up to 100 nodes.
path_prefix A repo-relative path. Matches the path itself and everything beneath it (path_prefix=src matches src and src/..., not srcx).

Response fields:

Field Meaning
project_id The requested project.
branch The branch whose ledger was returned. null when no branch is attached — reason then says so.
changes The events, in the requested order. The first row of a new ledger is a baseline with node_id, path, node_kind, qname, other_node and other_path all null.
next_after The id of the last event returned, in either order. Pass it as after on the next asc call, or as before on the next desc call. When changes is empty, it echoes the caller's cursor.
truncated More events exist after this page. A fact about the page, not the ledger.
limit_clamped Whether the caller's limit was lowered to the server ceiling.
effective_limit The limit actually applied.
history_applied_at When the ready index's ledger was last advanced.
history_skipped_reason Why the ready index recorded no ledger advance, when applicable. The string ledger error (<Class>) indicates a write that the store refused.
reason Why history is unavailable, when no branch is attached. Distinct from history_skipped_reason.

Events:

Event Meaning
baseline The first row of a fresh history ledger. It carries the index's real commit hash.
born A new node appeared on this commit. node_id, path, node_kind are set; qname is set on symbol rows. A move does not raise born for the destination: the new node is created alongside the old one's moved.
removed A node disappeared on this commit. path is the last known path.
moved A move makes a new node at the new path. The old node records this moved event with other_node set to the new node's id and other_path set to the new node's path; the new node gets no born.
revived A previously removed node returned: the path came back and the node id is the same one as before.

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

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

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

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

Errors

Error When Resolution
401 The token is missing, malformed, expired or revoked. Send a valid PAT.
403 The token does not carry code:read. Request a token that includes code:read.
404 index not found: the project has no attached repository, belongs to another workspace, or project_id is not a UUID. Pass the project's id, and attach a repository to it; these cases answer alike on purpose.
422 after with order=desc, before with order=asc, more than 100 node_id values, or a bad limit or format. Use the cursor that matches the order, and fix the parameter detail names.
503 The index database could not be reached. Retry with backoff.

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

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

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

Query parameters:

Parameter Meaning
node_id The node to expand. Required.
direction One of in (what imports this node), out (what this node imports), or both.
depth 1 or 2. Any other value is a 422.
limit Max neighbours to return. Defaults to 100; values above are clamped to 500 and limit_clamped reports it.
branch The branch whose ready index answers. Defaults to the attached branch.

Response fields:

Field Meaning
project_id The requested project.
node_id The requested root node.
focus The root node itself: node_id, path, node_kind and state. It is present even when neighbours is empty, so a client can draw a file that imports nothing and that nothing imports.
commit_hash The commit of the ready index that answered.
branch The branch that was used. Defaults to the attached branch.
direction The direction actually applied.
depth The depth actually applied.
neighbours The neighbours reached at the requested depth. Each carries node_id, path, node_kind, state, kind, derivation and hop.
truncated Whether neighbours may have been dropped (a fact about the page, not the graph).
limit_clamped Whether the caller's limit was lowered to the server ceiling.
effective_limit The limit actually applied.

Each neighbour carries:

Field Meaning
node_id The neighbour's stable node id.
path The neighbour's repo-relative path.
node_kind One of file, dir, symbol.
state One of live, removed, moved.
kind The edge kind, always import. Calls and symbol imports are not returned here; see Calls and symbol imports.
derivation How the edge was derived: parsed (Python ast) or regex (TS/JS). The scanner emits no import edges for Go today.
hop 1 for a direct neighbour, 2 for one reached through a direct neighbour (only with depth=2).

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

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

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

Errors

Error When Resolution
401 The token is missing, malformed, expired or revoked. Send a valid PAT.
403 The token does not carry code:read. Request a token that includes code:read.
404 index not found: no attached repository, another workspace's project, no ready index, an unknown node, or an id that is not a UUID. Resolve the node with nodes/resolve first; check /tree for indexed.
422 depth is not 1 or 2, direction is not in, out or both, or format is unknown. Fix the parameter detail names.
503 The index database could not be reached. Retry with backoff.

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

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

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

Parameter Type Default Notes
dir string . Repo-relative directory to roll up; . is the repository root. At most 1,024 characters.
depth integer 2 Folder levels below dir, 1 to 10. Deeper files fold into their folder at this depth.
limit integer 250 The most folder rows and cross-folder link rows to return. A value above 500 is lowered to 500, and limit_clamped says so.
branch string the attached branch Answer from this branch's ready index.
format string JSON yaml or md.
curl -s "https://api.opvs.ai/api/v1/code/$PROJECT_ID/graph/folders?dir=src%2Fpayments&depth=2&limit=100" \
  -H "Authorization: Bearer $OPVS_PAT"
# → 200 {"indexed": true, "dir": "src/payments", "mutual_import_pairs": 2, "truncated": false, "effective_limit": 100}
# → 200 {"indexed": false, "dir": "src/payments", "depth": 2} when the project has no ready index yet
# → 422 when dir is not repo-relative, for example /etc or ../other
# → 401 when the token is missing or expired

Response:

{
  "project_id": "9d7642c4-8151-4c3e-977e-9f30d5a52844",
  "indexed": true,
  "commit_hash": "92f4a7d2b1c03a8a43bda19a2cfcda16b3680c5e",
  "branch": "main",
  "dir": "src/payments",
  "depth": 2,
  "folders": [
    {
      "path": "src/payments",
      "node_id": "7a1c2d3e-4f50-5a61-b7c8-d9e0f1a2b3c4",
      "files_on_edges": 8,
      "imports_inside": 14,
      "parsed": 12,
      "regex": 2
    },
    {
      "path": "src/payments/providers",
      "node_id": "5e6f7a8b-9c0d-5e1f-a2b3-c4d5e6f7a8b9",
      "files_on_edges": 5,
      "imports_inside": 7,
      "parsed": 6,
      "regex": 1
    }
  ],
  "links": [
    {"src": "src/payments", "dst": "src/payments/providers", "imports": 7, "parsed": 6}
  ],
  "hubs": {
    "in_degree": [
      {"path": "src/payments/charge.py", "node_id": "1a2b3c4d-5e6f-5789-a0b1-c2d3e4f50617", "imports": 9}
    ],
    "out_degree": [
      {"path": "src/payments/integrations/stripe_client.py", "node_id": "9a8b7c6d-5e4f-5302-a1b2-c3d4e5f60718", "imports": 6}
    ]
  },
  "mutual_import_pairs": 2,
  "truncated": false,
  "truncated_reason": null,
  "limit_clamped": false,
  "effective_limit": 100
}
Field Meaning
indexed Whether a ready index answered. false keeps the same shape with empty lists.
commit_hash, branch The ready index that answered.
dir, depth The directory key and depth actually used.
folders One row per folder: path, node_id, files_on_edges (files with at least one import edge), imports_inside (edges within it), and that total split into parsed and regex.
links Imports between two folders, one row per ordered pair: src, dst, imports, parsed.
hubs in_degree ranks the files most imported by others in the roll; out_degree ranks the files that import the most. Each row is path, node_id, imports.
mutual_import_pairs Pairs of files where A imports B and B imports A, both inside the roll.
truncated, truncated_reason Whether the answer was cut, and why: the index stopped early, a row bound was hit, or some edges could not be placed.
limit_clamped, effective_limit Whether limit was lowered, and the limit applied.

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

Errors

Error When Resolution
400 project_id is not a UUID. Pass the project's id.
401 The token is missing, malformed, expired or revoked. Send a valid PAT.
403 The token does not carry code:read. Request a token that includes code:read.
422 dir is absolute or climbs out of the repository, depth is outside 1 to 10, limit is below 1, or format is unknown. Fix the parameter detail names.
503 The index database could not be reached. Retry with backoff.

Calls and symbol imports

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

Edge kind From → to Languages derivation
symbol_import a file → the function or class it imports by name (from billing.money import round_half_even, import { roundHalfEven } from "./money") Python, TypeScript, JavaScript parsed for Python, regex for TS/JS
call a function or method → the function or method it calls Python parsed

Every one of these edges carries a resolution:

  • resolved: the target was found exactly, as a function in the same file, a name the file imports, a self. method of the same class, or an attribute of an imported module.
  • heuristic: the call names a function that is not defined or imported by name in the file, and exactly one module the file imports defines a function of that name. The edge points there. It is a best guess, never a choice between two candidates: a call that could mean two targets gets no edge at all.

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

Where to read them. 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

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

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

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

Understand the states

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

  • No ready index: indexed is false. The attached repository is identified by repo_url and repo_branch when available.
  • Index job queued or running: the job block reports the job state. A queued job with waiting_for_machine: true is ready to run but has not been claimed by an indexing machine.
  • Index job skipped or failed: job.reason_class and job.detail explain what happened. A skipped job is a recorded outcome, not a missing status.
  • Index attempt pending, failed, or abandoned: the attempt block describes the newest index run that has not produced a ready index. abandoned means a pending run stopped updating.
  • Ready index: indexed is true. The freshness block identifies the commit and branch. A newer job or attempt can appear beside the current map while it is being refreshed.

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

Understand partial and coarse results

Three flags describe three different cuts:

  • freshness.index_truncated means the index is partial. Use freshness.scan_truncated to tell whether the symbol walk stopped early. freshness.symbols_dropped_at_ingest counts symbols that were read but could not be stored. If freshness.enumeration_incomplete is true, directories may also be missing from the census.
  • directories_capped means the stored tree is coarser than the directory rollup produced by the scan. Deeper directories are normally folded into stored ancestors, so their subtree_* totals remain useful. Read directories_cap_reason for the exact exception or cap description.
  • truncated means this request's depth parameter omitted stored directories. It does not mean the scan stopped early. truncated_reason says what the request left out.

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

API routes used by the canvas

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

Route Purpose Query parameters Format
GET /api/v1/code/{project_id}/tree Directory map and index state branch, depth, format JSON, YAML, or Markdown
GET /api/v1/code/{project_id}/fidelity Parsed and pattern-pass symbol counts by directory branch JSON
GET /api/v1/code/{project_id}/files Files with symbols beneath a directory directory, direct, branch, limit, order, after JSON, YAML, or Markdown
GET /api/v1/code/{project_id}/symbols Symbols in one file, in source order file_path, branch, limit JSON

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

Get the tree

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

Example response:

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

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

Tree response fields

The top-level response contains:

Field Meaning
project_id The requested project.
indexed Whether a ready index was found.
repo_url The repository represented by the response.
repo_branch The attached branch when indexed is false; otherwise null.
freshness Commit, age, completeness, and extraction fidelity for a ready index.
directories Directory tiles returned at the requested depth.
directory_count Tiles in this response, including synthesized ancestors.
stored_count Scanner-produced directories stored before request-depth filtering.
total_count Directories emitted by the scanner before the stored-tree cap.
max_depth Deepest directory level in this response.
directories_capped Whether the stored tree was made coarser at ingest.
directories_cap_reason Why the stored tree was capped, when recorded.
truncated Whether this request's depth omitted stored directories.
truncated_reason What this request omitted.
attempt The newest non-ready index attempt, if one applies.
job The newest job for the attached branch, if one exists or a precondition blocks one.

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

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

Field Meaning
edge_count Import edges the index stored. null when the scan did not measure edges, which is not the same as a measured 0.
edge_derivation_counts edge_count split into parsed (Python) and regex (TypeScript and JavaScript). Both keys are present when edges were measured.
files_on_edges Files that appear on at least one import edge.
edges_incomplete true when the scan stopped early or import edges were dropped while storing the index. Every edge total is then a lower bound. Symbols dropped alone do not set it.

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

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

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

Drill-down response fields

  • /fidelity returns project_id, indexed, commit_hash, branch, max_depth, and directories. Each directory contains path and derivation_counts.
  • /files returns project_id, indexed, commit_hash, branch, directory, files, file_count, symbol_count, next_after (the last path of this page when order=path and another page exists), truncated, and truncated_reason. Each file contains file_path, symbol_count, and derivation_counts.
  • /symbols returns project_id, indexed, commit_hash, branch, file_path, symbols, symbol_count, truncated, and truncated_reason. Each symbol contains name, kind, line_start, line_end, signature, doc_first_line, and derivation.

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

For agents

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

Method Route
getTree GET /api/v1/code/{project_id}/tree
getSummary GET /api/v1/code/{project_id}/summary
listFiles GET /api/v1/code/{project_id}/files
listSymbols GET /api/v1/code/{project_id}/symbols
searchContext GET /api/v1/code/{project_id}/context
resolveNodes POST /api/v1/code/{project_id}/nodes/resolve
listNodeChanges GET /api/v1/code/{project_id}/nodes/changes
getGraph GET /api/v1/code/{project_id}/graph
getGraphFolders GET /api/v1/code/{project_id}/graph/folders (since 0.4.0)

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.

Powered by OPVS