Skip to content

Use the REST API

Everything the dashboard does is plain HTTP under /api/v1. This guide shows the full life of a repository with curl, with the responses you should expect. Field-by-field detail is in the REST API reference.

You need a running backend (see Quick start) and, for the search step, Algolia credentials in backend/.env. The examples use http://127.0.0.1:8001. CodeSage is self-hosted, so there is no public demo server.

Read the API key from backend/.env so it never appears in a command you type:

Export the key
export CODESAGE_API_KEY=$(sed -n 's/^CODESAGE_API_KEY=//p' backend/.env)
export BASE=http://127.0.0.1:8001

Reads need no key. Writes (create, edit, delete, process), the MCP tool call and the /ws socket do, and you send it in an X-API-Key header. If the backend runs without a key on loopback, the header is ignored.

Terminal window
curl -sS $BASE/health
Expected response
{
"status": "healthy",
"timestamp": "2026-10-26T23:48:31.547878+00:00",
"version": "1.0.0",
"components": {
"database": {
"status": "healthy"
},
"algolia": {
"status": "healthy"
}
}
}

If algolia says not_configured or degraded, the overall status is degraded. The backend still answers with 200, but creating a repository returns 503 until you set ALGOLIA_APP_ID and ALGOLIA_ADMIN_API_KEY. Only a database failure gives 503 from /health.

Terminal window
curl -sS -X POST $BASE/api/v1/repositories/ \
-H "Content-Type: application/json" \
-H "X-API-Key: $CODESAGE_API_KEY" \
-d '{"url": "https://github.com/pmndrs/zustand"}'

This response is illustrative. Creating a repository is a write call, so it was not run for this guide; the shape follows the response schema.

201 Created
{
"id": 2,
"name": "pmndrs/zustand",
"description": null,
"url": "https://github.com/pmndrs/zustand",
"branch": null,
"language": null,
"framework": null,
"commit_sha": null,
"status": "pending",
"error_message": null,
"total_files": 0,
"processed_files": 0,
"total_lines": 0,
"created_at": "2026-10-26T14:00:02Z",
"updated_at": null,
"processed_at": null
}

The backend checks the repository on GitHub before it creates anything (10 seconds, one attempt). It then starts indexing in the background and returns at once. commit_sha stays null until the run resolves the branch to a commit; times are UTC, with a Z suffix. Note the id. You use it in every later step.

To index a branch, add "branch": "release" or use a branch URL such as https://github.com/pmndrs/zustand/tree/release. An empty "branch": "" means the default branch.

Other git hosts go through the add_repository MCP tool. See Other git hosts.

Terminal window
curl -sS $BASE/api/v1/repositories/2/status

The next block is illustrative, because a live run is too short to catch. The one after it is real.

While indexing
{
"repository_id": 2,
"status": "processing",
"processed_files": 100,
"total_files": 120,
"progress": 83.33333333333334,
"error_message": null
}

progress is a percentage from 0 to 100. A small loop that waits for the end:

Wait until the run ends
while true; do
status=$(curl -sS $BASE/api/v1/repositories/2/status | jq -r .status)
echo "status: $status"
case "$status" in completed|failed) break ;; esac
sleep 3
done

When the run ends, the status is completed or failed:

Completed
{
"repository_id": 2,
"status": "completed",
"processed_files": 120,
"total_files": 120,
"progress": 100.0,
"error_message": null
}

A completed run can still report "error_message": "Partially indexed: ...". That means something was left out, for example files over the 1,000-file limit. The repository is searchable. A failed run puts the reason in error_message, for example No supported code files found in repository.

A real failed repository (id 1 in the test backend) reports its reason like this:

Failed
{
"repository_id": 1,
"status": "failed",
"processed_files": 0,
"total_files": 0,
"progress": 0.0,
"error_message": "Processing was interrupted by a server restart; reprocess the repository"
}

Polling works, but the WebSocket pushes each batch as it is stored. Open the socket, then send a subscribe frame. The full protocol is in the WebSocket reference.

With websocat, send the key as a header:

websocat
websocat -H "X-API-Key: $CODESAGE_API_KEY" ws://127.0.0.1:8001/ws

The server answers first:

{"type": "connected", "data": {"message": "Connected to CodeSage real-time updates", "timestamp": "2026-10-26T14:00:00.123456+00:00"}}

Type this line and press Enter:

{"type": "subscribe", "repository_id": 2}

The frames below are illustrative; their shape follows the WebSocket reference.

Frames you receive
{"type": "subscribed", "data": {"repository_id": 2}}
{"type": "repository_status", "data": {"repository_id": 2, "status": "processing", "name": "pmndrs/zustand", "processed_files": 100, "total_files": 120, "timestamp": "2026-10-26T14:00:19.552870+00:00"}}
{"type": "repository_status", "data": {"repository_id": 2, "status": "completed", "name": "pmndrs/zustand", "processed_files": 120, "total_files": 120, "timestamp": "2026-10-26T14:00:31.907316+00:00", "message": "Processing completed successfully"}}

Without websocat, Node 22 or later has a global WebSocket. This script sends the key as a codesage.key.<hex> subprotocol, the same way the dashboard does, and exits when the run ends:

watch.mjs
const key = process.env.CODESAGE_API_KEY ?? "";
const id = Number(process.argv[2]);
const hex = Array.from(new TextEncoder().encode(key), (b) => b.toString(16).padStart(2, "0")).join("");
const socket = new WebSocket("ws://127.0.0.1:8001/ws", key ? ["codesage", `codesage.key.${hex}`] : ["codesage"]);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame.type === "connected") {
socket.send(JSON.stringify({ type: "subscribe", repository_id: id }));
} else if (frame.type === "repository_status") {
const u = frame.data;
console.log(`${u.name}: ${u.status} ${u.processed_files}/${u.total_files}`);
if (u.status === "completed" || u.status === "failed") socket.close(1000);
} else if (frame.type === "error") {
console.error(frame.data.message);
}
};
socket.onclose = (event) => process.exit(event.code === 1000 ? 0 : 1);
Terminal window
CODESAGE_API_KEY=$CODESAGE_API_KEY node watch.mjs 2
Output
pmndrs/zustand: processing 100/120
pmndrs/zustand: completed 120/120

A close with code 1008 means the key is wrong. A close with 1013 means the server has too many connections.

Terminal window
curl -sS "$BASE/api/v1/repositories/?status=completed&limit=20"

The response is an array of repository objects, ordered by ID. You can filter by language and status, and page with skip and limit (1 to 500).

Real output for that request, from a backend holding one completed repository. It was indexed before commits were recorded, so commit_sha is null:

Expected response
[
{
"name": "pmndrs/zustand",
"description": null,
"url": "https://github.com/pmndrs/zustand",
"branch": null,
"language": "typescript",
"framework": "react",
"id": 2,
"commit_sha": null,
"status": "completed",
"error_message": null,
"total_files": 120,
"processed_files": 120,
"total_lines": 25117,
"created_at": "2026-10-26T23:32:19Z",
"updated_at": "2026-10-26T23:32:50Z",
"processed_at": "2026-10-26T23:32:50.170474Z"
}
]
Terminal window
curl -sS $BASE/api/v1/repositories/2/stats
Expected response
{
"repository_id": 2,
"status": "completed",
"total_files": 120,
"processed_files": 120,
"total_lines": 25117,
"total_entities": 1025,
"entities_by_type": {
"class": 22,
"doc_section": 398,
"function": 474,
"import": 131
},
"files_by_language": {
"css": 3,
"html": 2,
"javascript": 14,
"json": 7,
"markdown": 47,
"typescript": 34,
"yaml": 13
},
"entities_by_language": {
"javascript": 64,
"markdown": 398,
"typescript": 563
},
"language": "typescript",
"framework": "react"
}

entities_by_language counts entities per file language, so it lists only the languages that have any. The dashboard offers those as its search language filter.

To fetch one repository on its own, use GET /api/v1/repositories/2.

Search is an MCP tool, called through one HTTP endpoint. The body names the tool and its arguments. The response is always {tool, result, is_error, error}.

Terminal window
curl -sS -X POST $BASE/api/v1/ai/mcp/tools/call \
-H "Content-Type: application/json" \
-H "X-API-Key: $CODESAGE_API_KEY" \
-d '{"tool_name": "search_code", "arguments": {"query": "createStoreImpl", "repository": "pmndrs/zustand", "limit": 2}}'

Real output, trimmed: the snippet strings are shortened with ….

Expected response
{
"tool": "search_code",
"result": {
"query": "createStoreImpl",
"repository": "pmndrs/zustand",
"total_hits": 5,
"page": 0,
"pages": 3,
"results": [
{
"entity_name": "createStoreImpl",
"entity_type": "function",
"file_path": "src/vanilla.ts",
"line_start": 60,
"line_end": 97,
"language": "typescript",
"signature": "const createStoreImpl: CreateStoreImpl = (createState) => {",
"snippet": "const createStoreImpl: CreateStoreImpl = (createState) => {\n type TState = ReturnType<typeof createState>\n type Listener = (state: TState, prevState: TState) => void\n let state: TState\n const list…",
"description": "TypeScript function: createStoreImpl",
"url": "https://github.com/pmndrs/zustand/blob/HEAD/src/vanilla.ts#L60-L97"
},
{
"entity_name": "setState",
"entity_type": "function",
"file_path": "src/vanilla.ts",
"line_start": 66,
"line_end": 81,
"language": "typescript",
"signature": "const setState: StoreApi<TState>['setState'] = (partial, replace) => {",
"snippet": " const setState: StoreApi<TState>['setState'] = (partial, replace) => {\n // TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved\n // https://github.…",
"description": "TypeScript function: createStoreImpl.setState",
"url": "https://github.com/pmndrs/zustand/blob/HEAD/src/vanilla.ts#L66-L81"
}
]
},
"is_error": false,
"error": null
}

The links say HEAD because this repository was indexed before commits were recorded. After a reprocess they carry the indexed commit’s SHA instead, so their line numbers stay right after the branch moves on.

The other tools work the same way. This one reads a file:

Terminal window
curl -sS -X POST $BASE/api/v1/ai/mcp/tools/call \
-H "Content-Type: application/json" -H "X-API-Key: $CODESAGE_API_KEY" \
-d '{"tool_name": "get_file", "arguments": {"repository": "pmndrs/zustand", "path": "src/vanilla.ts", "start_line": 1, "end_line": 6}}'
Expected response
{
"tool": "get_file",
"result": {
"repository": "pmndrs/zustand",
"path": "src/vanilla.ts",
"language": "typescript",
"total_lines": 100,
"start_line": 1,
"end_line": 6,
"truncated": false,
"content": " 1| type SetStateInternal<T> = {\n 2| _(\n 3| partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],\n 4| replace?: false,\n 5| ): void\n 6| _(state: T | { _(state: T): T }['_'], replace: true): void"
},
"is_error": false,
"error": null
}

Tool failures are 200 responses with is_error: true. This one is real: searching the failed repository id 1 (gitlab.com/gitlab-org/gitlab-test) with search_code:

A tool error
{
"tool": "search_code",
"result": null,
"is_error": true,
"error": "Repository 'gitlab.com/gitlab-org/gitlab-test' failed to index: Processing was interrupted by a server restart; reprocess the repository. Reprocess it from the dashboard."
}

Check is_error as well as the HTTP status. The MCP tools reference lists every tool and message.

Terminal window
curl -sS -X PUT $BASE/api/v1/repositories/2 \
-H "Content-Type: application/json" -H "X-API-Key: $CODESAGE_API_KEY" \
-d '{"description": "Demo service"}'

The response is the updated repository object, now with updated_at set. (The responses in steps 7 to 9 are illustrative: they are write calls, so they were not run for this guide.) Only the fields you send change, and url cannot be changed.

Changing branch, language or framework while a run is active returns 409:

409 Conflict
{"detail": "Repository 2 is being processed; change branch when it finishes"}

Changing the branch does not re-index. Reprocess to index the new branch.

A completed repository needs force_reprocess=true. A failed or pending one does not.

Terminal window
curl -sS -X POST -H "X-API-Key: $CODESAGE_API_KEY" \
"$BASE/api/v1/repositories/2/process?force_reprocess=true"
202 Accepted
{"message": "Processing started for repository 2", "repository_id": 2}

The status is processing as soon as the call returns, so a poll never reads the old failed. The run replaces the old index only after it has fetched the new files. Go back to step 3 or 4 to follow it.

Without the flag on a completed repository:

409 Conflict
{"detail": "Repository is already processed; pass force_reprocess=true to reprocess it"}
Terminal window
curl -sS -X DELETE -H "X-API-Key: $CODESAGE_API_KEY" $BASE/api/v1/repositories/2
200 OK
{"message": "Repository 2 deleted successfully"}

This removes the stored files, entities and Algolia records. It returns 409 while a run is active, so wait for completed or failed first. It returns 502 if Algolia refuses the delete, and the repository stays.

Every error body has a detail key. Its shape depends on the status:

Status Body What to do
401 {"detail": "Invalid or missing API key"} Check $CODESAGE_API_KEY
403 {"detail": "Host not allowed"} and similar Use 127.0.0.1 or localhost, or add the host to ALLOWED_HOSTS
404 {"detail": "Repository 99 not found"} Check the ID
409 {"detail": "..."}, with repository_id for a duplicate Read the message. A duplicate tells you the existing ID
422 {"detail": [{"type": ..., "loc": [...], "msg": ...}]} or a string Fix the field named in loc, or read the string
429 {"detail": "Too many requests; try again in 60 seconds."} Wait for Retry-After seconds
500 {"detail": "Internal server error", "request_id": "..."} Look up the request_id in the backend log
502, 503, 504 {"detail": "..."} GitHub or Algolia problem. See the message

A validation error from a bad URL looks like this. The 422 body below and the 409 further down are illustrative, because they come from the create call:

Terminal window
curl -sS -X POST $BASE/api/v1/repositories/ \
-H "Content-Type: application/json" -H "X-API-Key: $CODESAGE_API_KEY" \
-d '{"url": "https://gitlab.com/group/project"}'
422 Unprocessable Entity
{
"detail": [
{
"type": "invalid_url",
"loc": ["body", "url"],
"msg": "Invalid GitHub URL (host must be github.com): https://gitlab.com/group/project",
"input": "https://gitlab.com/group/project"
}
]
}

Adding the same URL twice returns the existing ID, which is handy in scripts:

409 Conflict
{"detail": "pmndrs/zustand has already been added.", "repository_id": 2}

Create and process allow 100 requests a minute per client address. MCP tool calls and resource reads allow 30 a minute. A rejected request returns 429 with a Retry-After header in seconds. This loop honors it:

Retry after a 429
call() {
while true; do
headers=$(mktemp)
body=$(curl -sS -D "$headers" -X POST $BASE/api/v1/ai/mcp/tools/call \
-H "Content-Type: application/json" -H "X-API-Key: $CODESAGE_API_KEY" -d "$1")
if grep -q '^HTTP/[0-9.]* 429' "$headers"; then
wait=$(awk 'tolower($1)=="retry-after:" {print $2+0}' "$headers")
echo "rate limited; waiting ${wait:-60}s" >&2
sleep "${wait:-60}"
else
echo "$body"; rm -f "$headers"; return
fi
done
}
call '{"tool_name": "list_repositories", "arguments": {}}'

A 503 from create can also carry Retry-After, when GitHub’s API rate limit was reached. Set GITHUB_TOKEN to raise that limit. You can change the backend limits with RATE_LIMIT_DEFAULT, RATE_LIMIT_AI and ENABLE_RATE_LIMITING; see Configuration.