Skip to main content

API Reference

This document describes the HTTP API exposed by the backup dashboard backend server. All API endpoints are mounted under /api unless otherwise stated. The API is built with Fiber and uses PostgreSQL as the persistence layer.

Base URL: http://localhost:8080 (or the configured PORT, falling back to SERVER_PORT)

Authentication​

With API_REQUIRE_AUTH=true, every /api/* route, the Prometheus /metrics endpoint and /ws/live require the internal secret, sent as either header:

X-Internal-Secret: <INTERNAL_SECRET>
Authorization: Bearer <INTERNAL_SECRET>

Requests without it get 401 UNAUTHORIZED with WWW-Authenticate: Bearer realm="github-backup-api". The comparison is constant-time. The API refuses to start with API_REQUIRE_AUTH=true unless INTERNAL_SECRET has at least 32 characters. /health, /healthz, /ready and /readyz are always public. API_REQUIRE_AUTH defaults to true. The dashboard calls the API through its server-side proxy, which adds the secret. API_REQUIRE_AUTH=false serves the routes openly and logs a warning at startup; use it only for local development.

The former /api/internal/* routes were removed: they duplicated /api/*, which was mounted without authentication, so the secret protected nothing. Use /api/* with the secret instead.

Rate Limiting​

All /api routes are rate limited per client and minute; exceeding a limit returns 429 RATE_LIMITED.

CallerLimitKeyed on
Without the internal secret100 / minClient IP (see below)
With the secret and X-Forwarded-For (the dashboard proxy forwarding the browser's address)300 / minThe forwarded client IP
With the secret and no forwarded address (observatory, MCP server)not limited-

The client IP is the connection's peer address unless the peer is listed in TRUSTED_PROXIES (comma-separated IPs or CIDRs, default none). Then the TRUST_PROXY_HEADER header (default X-Forwarded-For) is read from the right and the first address that is not a trusted proxy is used, so a client cannot choose its bucket by adding entries to the header. On Render, set TRUSTED_PROXIES to the ranges its load balancer connects from.

Request IDs​

Every response carries X-Request-ID. A client-supplied X-Request-ID is kept when it matches [A-Za-z0-9._-]{1,64}; otherwise a random ID is generated. The ID appears in every log line of the request and in error bodies.


Table of Contents​

  1. Backups
  2. Dashboard & Metrics
  3. Analytics
  4. Repositories
  5. Logs
  6. Live Log Stream (SSE) and WebSocket
  7. Vector Embeddings & Hybrid Search
  8. AI Observatory & Agentic Chat
  9. Error Handling
  10. Data Models

Backups​

List Backup Runs​

Endpoint: GET /api/backups

Retrieve a paginated list of backup runs in reverse chronological order (newest first).

Query Parameters:

ParameterTypeDefaultMaxDescription
pageinteger1—Page number (1-indexed)
limitinteger50500Results per page

Response: 200 OK

{
"data": [
{
"id": 1,
"started_at": "2024-06-17T10:30:00Z",
"completed_at": "2024-06-17T10:45:30Z",
"status": "completed",
"duration_ms": 870000,
"total_repos": 45,
"successful": 42,
"failed": 2,
"skipped": 1,
"error_message": ""
}
],
"pagination": {
"page": 1,
"limit": 50,
"total_items": 150,
"total_pages": 3
}
}

Status Values: completed, failed, running (as written by the worker)


Get Latest Backup​

Endpoint: GET /api/backups/latest

Retrieve the most recent backup run (by started_at) with its linked fixes. Returns {"run": null} when there are no runs.

Response: 200 OK

{
"run": {
"id": 150,
"started_at": "2024-06-17T10:30:00Z",
"completed_at": "2024-06-17T10:45:30Z",
"status": "completed",
"duration_ms": 870000,
"total_repos": 45,
"successful": 42,
"failed": 2,
"skipped": 1,
"error_message": "",
"fixes": [
{"id": 4, "title": "Raise clone timeout", "commit_hash": "a1b2c3d", "author": "claude", "affected_runs": [149, 150]}
]
}
}

A run left running by a worker that stopped is marked failed with the error worker stopped without finishing by a background job (every 10 minutes, only while the database is in use); reading these endpoints never changes data.


Get Backup Run Details​

Endpoint: GET /api/backups/:id

Retrieve detailed information for a specific backup run, including all repository backup results. A non-numeric id returns 400, an unknown one 404.

Path Parameters:

ParameterTypeDescription
idintegerBackup run ID

Response: 200 OK

{
"run": {
"id": 1,
"started_at": "2024-06-17T10:30:00Z",
"completed_at": "2024-06-17T10:45:30Z",
"status": "completed",
"duration_ms": 870000,
"total_repos": 45,
"successful": 42,
"failed": 2,
"skipped": 1,
"error_message": ""
},
"results": [
{
"id": 1,
"run_id": 1,
"repo_full_name": "octocat/Hello-World",
"status": "completed",
"created_at": "2024-06-17T10:30:15Z",
"duration_ms": 5000,
"commit_hash": "abc123def456",
"archive_size_bytes": 1048576,
"error_message": ""
},
{
"id": 2,
"run_id": 1,
"repo_full_name": "octocat/Spoon-Knife",
"status": "failed",
"created_at": "2024-06-17T10:32:10Z",
"duration_ms": 3000,
"commit_hash": "",
"archive_size_bytes": 0,
"error_message": "Network timeout during clone"
}
]
}

Missing commit hashes and error messages are returned as empty strings.


Dashboard & Metrics​

Get Dashboard Statistics​

Endpoint: GET /api/dashboard/stats

Retrieve aggregated statistics for the dashboard overview, including totals, success rates, and latest analytics. All totals come from one statement batched with the latest analytics snapshot (one database round trip).

Timeout: 15 seconds (operations are cancelled if exceeded)

total_logs is exact while execution_logs has fewer than 10,000 rows and the planner estimate (pg_class.reltuples, refreshed by ANALYZE and autovacuum) above that, so the endpoint never counts the whole log table. largest_repository is the repository of the largest archive ever recorded.

Response: 200 OK

{
"total_runs": 150,
"total_repos": 1250,
"total_successful": 1180,
"total_failed": 45,
"total_skipped": 25,
"distinct_repos": 320,
"total_logs": 5420,
"success_rate": 94.4,
"last_run_status": "completed",
"last_run_at": "2024-06-17T10:45:30Z",
"avg_duration_ms": 900000,
"total_size_bytes": 536870912,
"largest_archive_bytes": 104857600,
"largest_repository": "octocat/large-repo",
"latest_analytics": {
"id": 1,
"run_id": 150,
"captured_at": "2024-06-17T10:45:30Z",
"head_commit": "abc123def456789",
"head_commit_message": "Merge pull request #1",
"head_commit_at": "2024-06-17T10:40:00Z",
"total_commits": 5420,
"branch_count": 12,
"tag_count": 3,
"tracked_files": 850,
"total_blob_size_bytes": 536870912,
"avg_blob_size_bytes": 631680,
"largest_blob_path": "data/archive.tar.gz",
"largest_blob_size_bytes": 104857600,
"archive_count": 320,
"total_archive_size_bytes": 536870912,
"avg_archive_size_bytes": 1677721,
"largest_archive_path": "archives/large-repo.tar.gz",
"largest_archive_size_bytes": 104857600
}
}

Get Metrics​

Endpoint: GET /api/metrics

Retrieve the runs of the last days days with their totals, plus fleet totals and the latest analytics snapshot, in one database round trip.

Query Parameters:

ParameterTypeDefaultDescription
daysinteger30Number of days to look back: values below 1 mean 30, values above 3650 are capped at 3650

Response: 200 OK

{
"runs": [ { "id": 149, "status": "completed", "started_at": "2024-06-16T10:30:00Z", "total_repos": 45, "successful": 45, "failed": 0, "skipped": 0, "duration_ms": 860000 } ],
"total_runs": 30,
"avg_duration_ms": 900000,
"total_successful": 1320,
"total_failed": 12,
"total_skipped": 18,
"distinct_repos": 45,
"total_logs": 5420,
"total_size_bytes": 536870912,
"largest_archive_bytes": 104857600,
"largest_repository": "octocat/large-repo",
"latest_analytics": { "run_id": 150, "captured_at": "2024-06-17T10:45:30Z" }
}

total_logs follows the same exact-or-estimate rule as /api/dashboard/stats.


Analytics​

List Analytics History​

Endpoint: GET /api/analytics/history

Retrieve all repository analytics snapshots in reverse chronological order.

Query Parameters:

ParameterTypeDefaultMaxDescription
pageinteger1—Page number
limitinteger50500Results per page

Response: 200 OK

{
"data": [
{
"id": 1,
"run_id": 150,
"captured_at": "2024-06-17T10:45:30Z",
"head_commit": "abc123def456789",
"head_commit_message": "Merge pull request #1",
"head_commit_at": "2024-06-17T10:40:00Z",
"total_commits": 5420,
"branch_count": 12,
"tag_count": 3,
"tracked_files": 850,
"total_blob_size_bytes": 536870912,
"avg_blob_size_bytes": 631680,
"largest_blob_path": "data/archive.tar.gz",
"largest_blob_size_bytes": 104857600,
"archive_count": 320,
"total_archive_size_bytes": 536870912,
"avg_archive_size_bytes": 1677721,
"largest_archive_path": "archives/large-repo.tar.gz",
"largest_archive_size_bytes": 104857600
}
],
"pagination": {
"page": 1,
"limit": 50,
"total_items": 150,
"total_pages": 3
}
}

Get Latest Analytics​

Endpoint: GET /api/analytics/latest

Retrieve the most recent analytics snapshot without pagination.

Response: 200 OK

{
"id": 1,
"run_id": 150,
"captured_at": "2024-06-17T10:45:30Z",
"head_commit": "abc123def456789",
"head_commit_message": "Merge pull request #1",
"head_commit_at": "2024-06-17T10:40:00Z",
"total_commits": 5420,
"branch_count": 12,
"tag_count": 3,
"tracked_files": 850,
"total_blob_size_bytes": 536870912,
"avg_blob_size_bytes": 631680,
"largest_blob_path": "data/archive.tar.gz",
"largest_blob_size_bytes": 104857600,
"archive_count": 320,
"total_archive_size_bytes": 536870912,
"avg_archive_size_bytes": 1677721,
"largest_archive_path": "archives/large-repo.tar.gz",
"largest_archive_size_bytes": 104857600
}

Get Analytics for Specific Run​

Endpoint: GET /api/analytics/:id

Retrieve the analytics snapshot for a specific backup run ID.

Path Parameters:

ParameterTypeDescription
idintegerBackup run ID

Response: 200 OK — Same structure as Get Latest Analytics


Repositories​

Get Tracked Repositories​

Endpoint: GET /api/repos

Retrieve every repository that has a backup result, each represented by its newest result (latest created_at, then highest id), largest archives first.

Query Parameters:

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger50Results per page (max 500)

Response: 200 OK

{
"data": [
{
"full_name": "octocat/Hello-World",
"last_status": "completed",
"last_commit_hash": "abc123def456",
"archive_size_bytes": 1048576,
"last_backed_up": "2024-06-17T10:45:30Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total_items": 320,
"total_pages": 7
}
}

Logs​

Get Execution Logs​

Endpoint: GET /api/logs

Retrieve stored execution logs for display in the UI.

Query Parameters:

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger50Results per page (max 500)
levelstring—Filter by log level, any case (info, warn, error, debug; the worker writes lower case)
run_idinteger—Filter by specific backup run ID (non-numeric: 400)

Without filters, pagination.total_items is exact below 10,000 log rows and the planner estimate above.

Response: 200 OK

{
"data": [
{
"id": 1,
"created_at": "2024-06-17T10:30:15Z",
"level": "INFO",
"message": "Starting backup run",
"repository": null,
"run_id": 1
},
{
"id": 2,
"created_at": "2024-06-17T10:30:20Z",
"level": "INFO",
"message": "Cloning repository",
"repository": "octocat/Hello-World",
"run_id": 1
},
{
"id": 3,
"created_at": "2024-06-17T10:32:10Z",
"level": "ERROR",
"message": "Network timeout during clone",
"repository": "octocat/Spoon-Knife",
"run_id": 1
}
],
"pagination": {
"page": 1,
"limit": 50,
"total_items": 5420,
"total_pages": 109
}
}

Live Log Stream (Server-Sent Events)​

Endpoint: GET /api/logs/stream

Streams new execution_logs rows as Server-Sent Events. It is under /api, so it is rate limited and, with API_REQUIRE_AUTH=true, needs the internal secret (the dashboard reads it through its server-side proxy).

Response headers: Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no.

Stream:

retry: 5000

id: 123
event: log
data: {"type":"log","id":123,"level":"info","message":"Cloning repository","repository":"octocat/Hello-World","timestamp":"2024-06-17T10:30:20.123456Z"}

: ping
  • The stream opens with retry: 5000, sends a : ping comment every 15 seconds and ends after 5 minutes; EventSource reconnects on its own and resumes.
  • By default only rows written after the request are sent.
  • Resuming: send Last-Event-ID: <id> (EventSource does this when it reconnects) or ?after_id=<id>. When both are present Last-Event-ID wins, because a reconnecting EventSource repeats its original URL with a stale after_id. The rows after that ID written before the stream started (at most the newest 1,000) are sent first, then live rows, without gaps or duplicates. A malformed or negative ID returns 400.
  • A client too slow to keep up is disconnected and resumes on reconnect; 503 means the live feed is unavailable.
const source = new EventSource('/api/logs/stream'); // through a same-origin proxy that adds the secret
source.addEventListener('log', (event) => console.log(JSON.parse(event.data)));

WebSocket​

Real-time Live Logs​

Endpoint: GET /ws/live

WebSocket stream of the same new execution_logs rows, as text messages with the JSON of the SSE data field. No history is replayed: a client receives the rows written after it connected.

  • Origin check: a handshake whose Origin header is not listed in WS_ALLOWED_ORIGINS (comma-separated, default: the dashboard origins of the CORS configuration, * allows any) is refused with 403. Clients that send no Origin (not browsers) are accepted.
  • Authentication: with API_REQUIRE_AUTH=true the handshake needs the internal secret header. Browsers cannot set it on a WebSocket, so browser clients must use the SSE endpoint through the dashboard proxy.
  • Keepalive: the server pings every 30 seconds and disconnects a client that has not answered within 75 seconds; every write has a 10 second deadline, and a client too slow to keep up is disconnected (close code 1013). On shutdown the server sends close code 1001.

Connection Example:

const ws = new WebSocket('ws://localhost:8080/ws/live');
ws.onmessage = (event) => console.log(JSON.parse(event.data));

Log Entry:

{
"type": "log",
"id": 123,
"level": "info",
"message": "Cloning repository",
"repository": "octocat/Hello-World",
"timestamp": "2024-06-17T10:30:20.123456Z"
}

The WebSocket and SSE subscribers share one database poller. It runs only while at least one client is connected, polls every 2 seconds, backs off to 15 seconds after a minute without new rows and returns to 2 seconds as soon as rows arrive, so an idle API does not keep a scale-to-zero database awake.


Health & Prometheus Metrics​

EndpointAuthDescription
GET /health, GET /healthzpublicLiveness
GET /ready, GET /readyzpublicReadiness (database ping; reports disconnected without the driver error)
GET /metricsinternal secret when API_REQUIRE_AUTH=truePrometheus text (or JSON with ?format=json); request series are keyed by route pattern (/api/backups/:id)

The Python Observatory provides high-performance vector search combining PostgreSQL Full-Text Search (FTS) and pgvector semantic embeddings with Reciprocal Rank Fusion (RRF) and live table fallbacks.

Endpoint: POST /search (Python Observatory: http://localhost:8000/search)

Execute a dual-layer hybrid search across embedding chunks and raw database tables.

Request Body:

{
"query": "authentication timeout during clone",
"source_types": ["execution_log", "backup_result", "investigation"],
"limit": 10,
"fts_weight": 0.3,
"semantic_weight": 0.7,
"rerank_model_id": "cohere/rerank-v3.5:free"
}

Response: 200 OK

{
"results": [
{
"id": 42,
"source_type": "execution_log",
"source_id": "402",
"content": "[ERROR] Network timeout during clone (repo: octocat/Spoon-Knife)",
"score": 0.892154,
"metadata": {
"repository": "octocat/Spoon-Knife",
"level": "ERROR",
"run_id": 1
},
"reranked": true
}
],
"generation_id": 1,
"model_id": "google/text-embedding-004",
"total": 1
}

The embedding and search endpoints below wrap their payload as {"success": true, "message": "...", "data": <payload>}; the examples show the payload. /search, /embeddings/status, /embeddings/switch-model and the other /embeddings/* actions need a session token (Authorization: Bearer <JWT>).

List Free Embedding Models​

Endpoint: GET /api/embedding-models

Retrieve dynamic list of 100% free embedding models fetched from OpenRouter with in-memory caching.

Response: 200 OK

{
"models": [
{
"id": "google/text-embedding-004",
"name": "Google: Text Embedding 004",
"context_length": 2048,
"dimensions": 768,
"is_free": true
}
],
"cached": true,
"active_model_id": "google/text-embedding-004"
}

List Free Reranking Models​

Endpoint: GET /api/reranking-models

Retrieve dynamic list of 100% free reranking models available via OpenRouter.

Response: 200 OK

{
"models": [
{
"id": "cohere/rerank-v3.5:free",
"name": "Cohere: Rerank 3.5 (Free)",
"context_length": 4096,
"is_free": true
}
],
"cached": true
}

Get Embedding Status​

Endpoint: GET /embeddings/status

Retrieve generation health, vector coverage, and pending/failed job progress.

Response: 200 OK

{
"active_generation": {
"id": 1,
"model_id": "google/text-embedding-004",
"status": "ACTIVE",
"dimensions": 768,
"chunk_count": 1250
},
"progress": {
"total": 1250,
"processed": 1250,
"failed": 0,
"percentage": 100.0
},
"pending_jobs": 0,
"failed_jobs": 0
}

Switch Embedding Model​

Endpoint: POST /embeddings/switch-model

Create a new BUILDING generation for a model, scan source records, chunk content, and enqueue background embedding jobs. The model's dimensions come from the model registry; chunking uses the service's settings.

Request Body:

{
"model_id": "google/text-embedding-004"
}

Response: 200 OK

{
"generation_id": 2,
"model_id": "google/text-embedding-004",
"status": "BUILDING",
"total_jobs_enqueued": 450,
"message": "Switched to model and enqueued source records"
}

Process Embedding Jobs Batch​

Endpoint: POST /embeddings/process-batch (requires a bearer token)

Claim and embed queued jobs of a generation. One request runs up to max_batches batches and stops early when no job is left or after EMBEDDING_BATCH_TIME_BUDGET_SECONDS (default 25). Each job is written in its own savepoint, so one bad job does not fail the batch. When the last job is done, a BUILDING generation becomes READY (or FAILED if every job failed).

Request Body:

{
"generation_id": 2,
"batch_size": 50,
"max_batches": 5
}

batch_size is optional (1 to 500, default 50); max_batches is optional (1 to 50, default 1).

Response: 200 OK

{
"success": true,
"message": "Batch processed",
"data": {
"processed": 250,
"succeeded": 249,
"failed": 1,
"batches": 5,
"generation_status": "BUILDING"
}
}

Counts are jobs (source records), not chunks. 400 when the generation does not exist.


Activate Embedding Generation​

Endpoint: POST /embeddings/activate?generation_id=2 (requires a bearer token)

Make a READY generation the one search uses. A BUILDING generation whose jobs are all finished is promoted to READY first. The generation it replaces is set to RETIRED and keeps its vectors until it is pruned; no generation is deleted here.

Response: 200 OK

{
"success": true,
"message": "Generation activated; the previously active generation was retired",
"data": {
"generation_id": 2,
"status": "ACTIVE",
"retired_generation_ids": [1]
}
}

404 for an unknown generation id, 409 when the generation is not READY (for example still BUILDING with pending jobs, or FAILED). Activating the active generation changes nothing.


Prune Stale Embedding Generations​

Endpoint: POST /embeddings/prune (requires a bearer token)

Delete RETIRED and FAILED generations with their chunks and jobs. BUILDING, READY and ACTIVE generations are never pruned.

Response: 200 OK

{
"success": true,
"message": "Retired and failed embedding generations and stale failed jobs pruned",
"data": {
"deleted_generations": 1,
"deleted_jobs": 0
}
}

AI Observatory & Agentic Chat​

The AI Observatory provides an autonomous agent with LangChain tool-calling RAG, SSE streaming, and Human-In-The-Loop (HITL) approval workflows. Every endpoint below except /auth/login needs Authorization: Bearer <token>.

Login​

Endpoint: POST /auth/login (form-encoded username, password)

Response: 200 OK

{"access_token": "<jwt>", "token_type": "bearer"}

Tokens are HS256 JWTs with issuer and audience github-backup (JWT_ISSUER, JWT_AUDIENCE) and an expiry; tokens without them are rejected. 401 for wrong credentials. After 5 failed attempts from one IP address within 5 minutes (LOGIN_MAX_FAILURES, LOGIN_FAILURE_WINDOW_SECONDS) the endpoint returns 429 with a Retry-After header. The address is the connection's peer, except for requests that carry a valid X-Internal-Secret: the dashboard server sends every browser's login with the secret and the browser's address in X-Client-Address, and that address is used instead, so one browser cannot lock out the others. The limit is kept in process memory, so each instance counts separately and a restart clears it.

Current User​

Endpoint: GET /auth/me

Response: 200 OK

{"success": true, "message": "Token is valid", "data": {"username": "octocat"}}

401 for a missing, expired or invalid token.


Connect an OpenRouter Account​

Endpoints: POST /me/openrouter/exchange, GET /me/openrouter, DELETE /me/openrouter

Users can run chat, search and embeddings on their own OpenRouter key. The dashboard runs the OpenRouter PKCE flow (S256) and posts the returned code with its verifier:

{"code": "<code from the OpenRouter callback>", "code_verifier": "<43 to 128 characters>"}

The observatory exchanges the code at POST https://openrouter.ai/api/v1/auth/keys and stores the key encrypted with CREDENTIALS_ENCRYPTION_KEY. The key is never returned. All three endpoints return the connection status:

{
"success": true,
"message": "OpenRouter connection status",
"data": {
"connected": true,
"source": "user",
"key_hint": "sk-or-...a1b2",
"connected_at": "2026-09-24T08:00:00+00:00",
"last_used_at": "2026-09-24T08:05:00+00:00",
"invalid": false
}
}

When no key is connected, connected is false and source is server (the shared server key pool is used) or none (no server keys, or OPENROUTER_ALLOW_SERVER_KEYS=false). Errors: 503 CREDENTIAL_STORAGE_UNAVAILABLE when CREDENTIALS_ENCRYPTION_KEY is not set or the table is missing, 400 OPENROUTER_EXCHANGE_FAILED when OpenRouter refuses the code, 502 OPENROUTER_EXCHANGE_UNAVAILABLE when OpenRouter cannot be reached.

A connected key is used only for its owner and requests never fall back to other keys. When OpenRouter rejects it (401) it is marked invalid and requests fail with 403 OPENROUTER_KEY_INVALID until the user reconnects; 402 OPENROUTER_OUT_OF_CREDITS and 429 OPENROUTER_RATE_LIMITED are passed on. 403 is used instead of 401 so the dashboard does not log the user out.


Chat and Chat Stream​

Endpoints: POST /chat (JSON answer), POST /chat/stream (Server-Sent Events). GET /chat also answers, with the same fields as query parameters; there is no GET /chat/stream.

Request Body:

{
"session_id": "3f0a2c1e-5b7d-4e8f-9a6b-1c2d3e4f5a6b",
"question": "Investigate clone failure in repo octocat/Spoon-Knife",
"model": "openrouter/free-model:free"
}

session_id must be a UUID (422 otherwise); model is optional and must be the default OPENROUTER_MODEL or a free model from the OpenRouter registry (400 MODEL_NOT_ALLOWED, 503 MODEL_REGISTRY_UNAVAILABLE when the registry cannot be read). The same check applies to rerank_model_id on POST /search.

The question is saved before the model is called, and the turn is saved with its final status (completed or failed). The model sees the last AGENT_HISTORY_MAX_MESSAGES (default 20) messages of the session, role and content only.

SSE Event Types (see docs/STREAMING_ARCHITECTURE.md for the full contract):

  • {"type": "info", "request_id": "...", "session_id": "...", "sources": [...]}: retrieved context, always first.
  • {"type": "agent_reasoning", "iteration": 1, "request_id": "..."}: a model iteration started.
  • {"type": "token", "text": "..."}: streamed answer text.
  • {"type": "confirm_required", "confirm_id": "uuid", "name": "send_report_email", "args": {...}, "tool_call_id": "..."}: HITL approval required.
  • {"type": "tool_start", "name": "...", "args": {...}, "tool_call_id": "..."} and {"type": "tool_end", "name": "...", "tool_call_id": "...", "success": true, "duration_ms": 12.4, "result": "..."} (error instead of result on failure).
  • {"type": "error", "message": "...", "code": "DEADLINE_EXCEEDED", "request_id": "..."}.
  • {"type": "done", "answer": "...", "request_id": "..."}: always the last event.

POST /chat never runs send_report_email, because it cannot ask for confirmation; the agent is told to use the streaming chat for that.


Human-In-The-Loop Confirmation​

Endpoint: POST /chat/confirm

Approve or reject a pending sensitive tool action announced by a confirm_required event.

Request Body:

{
"confirm_id": "uuid",
"approve": true
}

approved is accepted as an alias of approve.

Response: 200 OK

{
"success": true,
"message": "Confirmation recorded successfully",
"data": {"confirm_id": "uuid", "approved": true, "status": "approved"}
}

Only the user who started the turn can decide. 404 when the id is unknown or belongs to another user, 409 when it was already decided or has expired (after 120 seconds without a decision, which counts as rejected), 422 for a malformed id, 503 when the confirmation store is unavailable.


List Chat Sessions​

Endpoint: GET /sessions

Retrieve the chat sessions. GET /sessions/{id}, PATCH /sessions/{id}, DELETE /sessions/{id} and GET /sessions/{id}/messages return 404 for unknown or malformed ids.

Response: 200 OK

{
"success": true,
"message": "Sessions list retrieved",
"data": [
{
"id": "3f0a2c1e-5b7d-4e8f-9a6b-1c2d3e4f5a6b",
"session_name": "Investigate clone failure in octocat/Spoon-Knife",
"created_at": "2026-09-24T10:30:00Z",
"updated_at": "2026-09-24T10:34:00Z"
}
]
}

Observatory Errors and Readiness​

Errors use {"success": false, "error": {"code": "...", "message": "..."}, "request_id": "..."}. Unexpected failures return 500 with the message Internal server error and the request_id (also in the X-Request-ID header); the exception text is only logged. 503 SERVICE_UNAVAILABLE means chat history storage is unavailable.

GET /ready returns 200 or 503 with one status word per component (database, go_backend, configuration) to everyone; the error details behind them (exception text, status codes) only go to callers with a user token or X-Internal-Secret. CORS accepts only the origins in CORS_ALLOWED_ORIGINS.

Report endpoints (POST /reports, POST /reports/send): a malformed report_id gives 422, an unknown report type or recipients outside the SMTP_TO allowlist give 400, and a failed delivery gives 502 EMAIL_DELIVERY_FAILED instead of a success response. Reports no longer have a pdf_path.


Error Handling​

The API returns standard HTTP status codes and error responses:

Status CodeMeaning
200Success
400Bad Request (invalid query parameters, etc.)
404Not Found (requested resource doesn't exist)
429Too Many Requests (rate limit exceeded)
500Internal Server Error
503Service Unavailable (database connection issue)

Error Response Format:

Validation and lookup errors (400, 404) return {"error": "<message>"}. Authentication, rate limit and internal errors use the envelope:

{
"success": false,
"error": {
"code": "INTERNAL_ERROR",
"message": "internal server error (request_id 5f1c2b0e9a7d4c3b8e6f1a2d3c4b5a69)",
"request_id": "5f1c2b0e9a7d4c3b8e6f1a2d3c4b5a69"
},
"message": "internal server error (request_id 5f1c2b0e9a7d4c3b8e6f1a2d3c4b5a69)"
}

Internal errors never include database or driver messages; the details are logged with the same request ID. A panic in a handler is recovered and answered the same way.


Data Models​

BackupRun​

FieldTypeDescription
idintegerUnique identifier
started_atISO 8601 timestampWhen the run started
completed_atISO 8601 timestamp or nullWhen the run completed
statusstringOne of: completed, failed, running
duration_msintegerTotal duration in milliseconds
total_reposintegerTotal repositories in the run
successfulintegerSuccessfully backed up repos
failedintegerFailed backups
skippedintegerSkipped repos (no changes detected)
error_messagestringError details (from backup_run_errors / backup_result_errors), empty when none

BackupResult​

FieldTypeDescription
idintegerUnique identifier
run_idintegerAssociated backup run ID
repo_full_namestringRepository full name (owner/repo)
statusstringcompleted or failed; the worker also records empty (no branches or tags to archive) and too_large (over the git sink's 100 MiB file limit). Rows written by the old worker may say success.
created_atISO 8601 timestampWhen the result was created
duration_msintegerTime taken to backup this repo
commit_hashstringGit commit hash of the backup, empty when none
archive_size_bytesintegerSize of the generated archive
error_messagestringError details (from backup_run_errors / backup_result_errors), empty when none

RepoAnalyticsSnapshot​

FieldTypeDescription
idintegerUnique identifier
run_idinteger or nullAssociated backup run ID
captured_atISO 8601 timestampWhen the snapshot was taken
head_commitstringLatest commit hash
head_commit_messagestringLatest commit message
head_commit_atISO 8601 timestamp or nullWhen the latest commit was made
total_commitsintegerTotal commits in backup repo
branch_countintegerNumber of branches
tag_countintegerNumber of tags
tracked_filesintegerTotal files being tracked
total_blob_size_bytesintegerTotal blob size
avg_blob_size_bytesintegerAverage blob size
largest_blob_pathstringPath to largest blob
largest_blob_size_bytesintegerSize of largest blob
archive_countintegerNumber of archives
total_archive_size_bytesintegerTotal archive size
avg_archive_size_bytesintegerAverage archive size
largest_archive_pathstringPath to largest archive
largest_archive_size_bytesintegerSize of largest archive

ExecutionLog​

FieldTypeDescription
idintegerUnique identifier
created_atISO 8601 timestampWhen the log was created
levelstringLog level as written by the worker: info, warn, error
messagestringLog message
repositorystringAssociated repository, empty when none
run_idinteger or nullAssociated backup run ID

DashboardStats​

FieldTypeDescription
total_runsintegerTotal number of backup runs
total_reposintegerTotal repositories processed
total_successfulintegerTotal successful backups
total_failedintegerTotal failed backups
total_skippedintegerTotal skipped backups
distinct_reposintegerUnique repositories backed up
success_ratefloatSuccess percentage (0-100)
last_run_statusstringStatus of the last run: completed, failed or running
last_run_atISO 8601 timestamp or nullWhen the last run occurred
avg_duration_msintegerAverage run duration
total_size_bytesintegerTotal backup size
largest_archive_bytesintegerSize of largest archive
largest_repositorystringName of largest repository backup
latest_analyticsRepoAnalyticsSnapshot or nullMost recent analytics snapshot

Implementation Notes​

  • All responses are JSON-formatted.
  • Timestamps are in ISO 8601 format with UTC timezone.
  • All sizes are in bytes.
  • Durations are in milliseconds.
  • The API relies on PostgreSQL for persistence; ensure DATABASE_URL is configured and the database is running.
  • All /api routes are rate-limited. Implement exponential backoff when receiving 429 responses.
  • The API has a 15 second read timeout and a 2 minute keep-alive idle timeout, and deliberately no write timeout (it would cut the live log stream).
  • Response handlers are implemented in src/backend/github-backup-api/handlers/ directory.
  • Routes are defined in src/backend/github-backup-api/routes/app.routes.go.