The REST API is served by stackunderflow start (or its alias stackunderflow init) on
http://localhost:8081 by default — change the port with --port or stackunderflow cfg set port <N>.
FastAPI's auto-generated Swagger UI is at /docs. This file is the human-written companion: it explains
intent, payload shapes, and status codes. Most endpoints return JSON; the export, meta-agent, and live
routes stream instead.
Error shapes. Two forms appear, depending on where the failure is raised:
- Routes that raise
HTTPException(most400/404/409/503cases) return FastAPI's{"detail": "<message>"}. - Service-level failures and most
500cases return{"error": "<message>"}. - A missing or mistyped query/path parameter is caught by FastAPI validation and returns
422with a{"detail": [ ... ]}list.
Currency. Most endpoints that return dollar figures — /api/dashboard-data, /api/stats,
/api/projects, /api/cost-data, /api/cost-data/by-provider, /api/commands, /api/jsonl-files,
/api/sessions/compare, /api/yield, /api/plan — carry a top-level
currency: {code, symbol, rate_from_usd} block, and their cost amounts are pre-converted from USD into the
active currency (set via stackunderflow cfg set currency GBP or STACKUNDERFLOW_CURRENCY=GBP).
/api/global-stats and /api/compare are the exceptions: both return raw USD with no currency block.
FX rates are fetched from Frankfurter and cached for 24h; if a fetch fails the API falls back to USD with
rate_from_usd=1.0.
| Method | Path | Group |
|---|---|---|
| GET | /api/project |
Projects |
| POST | /api/project |
Projects |
| POST | /api/project-by-dir |
Projects |
| GET | /api/projects |
Projects |
| GET | /api/recent-projects |
Projects |
| GET | /api/providers |
Projects |
| GET | /api/global-stats |
Projects |
| GET | /api/stats |
Dashboard data |
| GET | /api/dashboard-data |
Dashboard data |
| GET | /api/messages |
Dashboard data |
| GET | /api/messages/summary |
Dashboard data |
| POST | /api/refresh |
Dashboard data |
| GET | /api/cost-data |
Cost analytics |
| GET | /api/cost-data/by-provider |
Cost analytics |
| GET | /api/commands |
Cost analytics |
| GET | /api/tool-distribution |
Cost analytics |
| GET | /api/interaction/{id} |
Cost analytics |
| GET | /api/jsonl-files |
Sessions |
| GET | /api/jsonl-content |
Sessions |
| GET | /api/sessions/compare |
Sessions |
| GET | /api/search |
Search |
| POST | /api/search/reindex |
Search |
| GET | /api/search/stats |
Search |
| GET | /api/qa |
Q&A |
| GET | /api/qa/{id} |
Q&A |
| POST | /api/qa/reindex |
Q&A |
| GET | /api/qa/stats |
Q&A |
| GET | /api/tags |
Tags |
| GET | /api/tags/browse/{tag} |
Tags |
| GET | /api/tags/session/{id} |
Tags |
| POST | /api/tags/session/{id} |
Tags |
| DELETE | /api/tags/session/{id}/{tag} |
Tags |
| POST | /api/tags/reindex |
Tags |
| GET | /api/bookmarks |
Bookmarks |
| POST | /api/bookmarks |
Bookmarks |
| PUT | /api/bookmarks/{id} |
Bookmarks |
| DELETE | /api/bookmarks/{id} |
Bookmarks |
| POST | /api/bookmarks/toggle |
Bookmarks |
| GET | /api/bookmarks/session/{id} |
Bookmarks |
| GET | /api/cfg |
Settings |
| GET | /api/cfg/currencies |
Settings |
| POST | /api/cfg/currency |
Settings |
| GET | /api/cfg/model-aliases |
Settings |
| POST | /api/cfg/model-aliases |
Settings |
| DELETE | /api/cfg/model-aliases |
Settings |
| GET | /api/export |
Export |
| GET | /api/compare |
Compare |
| GET | /api/yield |
Yield |
| GET | /api/plan |
Plan |
| GET | /api/optimize |
Optimize |
| GET | /api/context-budget |
Context Budget |
| GET | /api/etl/status |
ETL pipeline |
| POST | /api/etl/backfill |
ETL pipeline |
| GET | /api/agent-teams |
Agent teams |
| GET | /api/agent-teams/{session_id} |
Agent teams |
| GET | /api/agent-teams/{session_id}/agent/{agent_session_id} |
Agent teams |
| GET | /api/playback/{session_id} |
Playback |
| GET | /api/playback/project/{slug} |
Playback |
| GET | /api/playback/{session_id}/fs |
Playback |
| GET | /api/meta-agent/tools |
Meta-agent |
| POST | /api/meta-agent/chat |
Meta-agent |
| GET | /api/live/stats |
Live |
| GET | /api/live/stream |
Live |
| POST | /api/webhooks/github |
Webhooks |
| POST | /api/webhooks/gitlab |
Webhooks |
| POST | /api/webhooks/ci |
Webhooks |
| GET | /api/health |
Misc |
| GET | /api/pricing |
Misc |
| POST | /api/pricing/refresh |
Misc |
| GET, POST, PUT, DELETE | /ollama-api/{path} |
Misc |
Returns the currently selected project. When no project has been set, status is "no_project".
Response (no project selected)
{"status": "no_project", "message": "No project selected"}Response (project active)
{
"status": "active",
"project_path": "Users/yadkonrad/dev/myproject",
"log_path": "/Users/yadkonrad/.claude/projects/-Users-yadkonrad-dev-myproject",
"log_dir_name": "-Users-yadkonrad-dev-myproject"
}Status codes: 200 always.
Set the active project by filesystem path. The server locates the Claude log directory automatically.
Request body
{"project_path": "/Users/yadkonrad/dev/myproject"}Response
{
"status": "success",
"project_path": "/Users/yadkonrad/dev/myproject",
"log_path": "/Users/yadkonrad/.claude/projects/-Users-yadkonrad-dev-myproject",
"message": "Project set successfully. You can now view the dashboard."
}Status codes: 200 success; 400 missing or non-existent project_path;
404 project path exists but no Claude logs were found there.
Set the active project using the Claude log directory slug (the ~/.claude/projects/<slug> folder name).
This is the endpoint the React UI calls when the user picks a project from the sidebar.
Request body
{"dir_name": "-Users-yadkonrad-dev-dev-year26-jan26-StackUnderflow"}Response
{
"status": "success",
"project_path": "Users/yadkonrad/dev/dev/year26/jan26/StackUnderflow",
"log_path": "/Users/yadkonrad/.claude/projects/-Users-yadkonrad-dev-dev-year26-jan26-StackUnderflow",
"log_dir_name": "-Users-yadkonrad-dev-dev-year26-jan26-StackUnderflow",
"message": "Now analyzing logs from: -Users-yadkonrad-dev-dev-year26-jan26-StackUnderflow"
}Status codes: 200 success; 400 missing dir_name or path traversal attempt;
404 directory not found or contains no .jsonl files.
List all known projects from the session store with metadata. Supports sorting and pagination.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
include_stats |
bool | false |
Include per-project statistics (slower) |
sort_by |
string | last_modified |
One of last_modified, first_seen, size, name |
limit |
int | none | Max results to return |
offset |
int | 0 |
Skip this many results (for pagination) |
provider |
repeated string | (none) | Repeated query param (?provider=cursor&provider=cline) scoping the list to those providers. Case-insensitive; empty means all. |
Response
{
"projects": [
{
"dir_name": "-Users-yadkonrad-dev-myproject",
"log_path": "/Users/yadkonrad/.claude/projects/-Users-yadkonrad-dev-myproject",
"file_count": 0,
"total_size_mb": 0.0,
"last_modified": 1776649168.04,
"first_seen": 1772585609.12,
"display_name": "-Users-yadkonrad-dev-myproject",
"in_cache": false,
"url_slug": "-Users-yadkonrad-dev-myproject",
"provider": "claude",
"providers": ["claude"],
"stats": null
}
],
"total_count": 143,
"has_more": true,
"cache_status": {"cached_count": 0, "total_projects": 143},
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}provider is the most-recent provider that ingested the slug; providers is the sorted list of every
provider with a row for that slug (the schema has UNIQUE(provider, slug), so one project used through both
Claude and Cursor yields two rows that the route collapses into one card). file_count is the project's
session count, summed across those provider rows. When include_stats=true and a non-USD currency is
active, each stats.total_cost is pre-converted; other stats figures stay raw.
Status codes: 200 always (errors return 500 with {"error": "..."}).
The 20 most recently modified projects, in a lighter shape than /api/projects. No query parameters.
Unlike /api/projects, rows are not collapsed by slug, so a project used through two providers can appear
twice. file_count is always 0 here (the store does not track a per-project file count for this view).
Response
{
"projects": [
{
"dir_name": "-Users-yadkonrad-dev-myproject",
"log_path": "",
"last_modified": 1776649168.04,
"file_count": 0
}
]
}Status codes: 200 always. On a database error the body is {"projects": [], "error": "..."} with
status 200.
Every distinct provider in the store, with project and session counts. Powers the dashboard's provider filter chips. No query parameters.
Response
{
"providers": [
{"provider": "claude", "project_count": 180, "session_count": 1042},
{"provider": "cursor", "project_count": 6, "session_count": 23}
]
}Providers sort by project_count descending; a null provider column reports as "unknown".
Status codes: 200 success. On error the body is {"providers": [], "error": "..."} with status 500.
Aggregated statistics across all projects in the store. This is the only dashboard endpoint that does not require a project to be selected. The Overview page calls this endpoint exclusively.
See the Data Shapes Appendix for the full field reference.
Response (abbreviated)
{
"first_use_date": "2025-11-29",
"last_use_date": "2026-04-20",
"daily_token_usage": [{"date": "2025-11-29", "input": 0, "output": 0}],
"daily_costs": [{"date": "2025-11-29", "cost": 0.0, "by_model": {}}],
"models": {
"claude-opus-4-6": {"count": 57584, "cost": 29402.30}
},
"total_cache_read_tokens": 16328970052,
"total_cache_write_tokens": 1126449344,
"config": {"max_date_range_days": 30}
}Status codes: 200 success; 500 on database error.
Project scoping:
/api/stats,/api/dashboard-data,/api/messages, and/api/messages/summaryall act on the current project — the one most recently set viaPOST /api/project-by-dirorPOST /api/project. If no project has been selected they return400 {"error": "No project selected"}./api/global-stats(above) is the only aggregate endpoint that does not require a project.POST /api/refreshrefreshes the current project when one is selected; if no project is set it refreshes all projects instead.
Full statistics object for the current project, computed via the pipeline
(classifier → enricher → aggregator).
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
timezone_offset |
int | 0 |
Minutes offset from UTC for daily bucketing |
days |
int | 90 |
Cap daily_stats to the most recent N calendar days. Pass 0 to disable the cap. |
include |
repeated string | (none) | Repeated query param — return only the named top-level blocks (e.g. ?include=overview&include=models). currency always passes through. |
details |
bool | false |
When false, strip the heaviest per-row lists (user_interactions.command_details, user_interactions.tool_count_distribution, errors.assistant_details, errors.error_details, and the top-level session_costs / command_costs / session_efficiency / retry_signals lists; outliers lists are capped at 10 entries). On large stores this drops the payload from ~4 MB to ~150 KB while keeping every key present (empty list/dict where stripped). Set true to opt back into the legacy full-body response. |
Response — the top-level statistics dict (same as the statistics key in /api/dashboard-data).
Key nested sections include overview, tools, sessions, daily_usage, models, costs.
{
"overview": {
"project_name": "StackUnderflow",
"log_dir_name": "-Users-yadkonrad-dev-dev-year26-jan26-StackUnderflow",
"total_messages": 11341,
"date_range": {"start": "2026-01-30T20:58:11.193Z", "end": "2026-04-20T01:39:11.887Z"},
"sessions": 20,
"message_types": {"user": 4381, "assistant": 6960},
"total_tokens": {
"input": 600390, "output": 3030862,
"cache_creation": 86450124, "cache_read": 1466551362
},
"total_cost": 3767.61
},
"tools": {
"usage_counts": {"Bash": 1296, "Read": 700, "Edit": 531},
"error_counts": {},
"error_rates": {"Bash": 0.0}
}
}Status codes: 200 success; 400 no project selected; 404 project not in store (run /api/refresh).
Single call for the initial dashboard load — statistics plus the first 50 messages in one round-trip. The
heavy analytics sections live behind /api/cost-data, /api/commands, and /api/tool-distribution; this
payload keeps only the summary so initial paint stays fast.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
timezone_offset |
int | 0 |
Minutes offset from UTC for daily bucketing |
provider |
repeated string | (none) | Scope to those providers. The current project belongs to one provider; an active filter that excludes it returns an empty payload with filtered: true. |
model |
repeated string | (none) | Scope the top-level models map to these model ids |
Response
{
"statistics": {"overview": {"...": "..."}, "tools": {"...": "..."}},
"messages_page": {
"messages": [{"...": "..."}],
"total": 11341,
"page": 1,
"per_page": 50,
"total_pages": 227,
"start_index": 0,
"end_index": 50
},
"message_count": 11341,
"is_reindexing": false,
"config": {
"messages_initial_load": 50,
"max_date_range_days": 30
},
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}When a provider filter excludes the active project, the response is an empty-but-shape-stable body with
"filtered": true and a zeroed messages_page.
Status codes: 200 success; 400 no project; 404 project not in store.
A page of pipeline-formatted messages for the current project. Returns a paginated envelope, not a bare array. Default page size is 100; the maximum is 500. Earlier releases returned the full unbounded list, which ballooned to ~37 MB on a 26K-message project — pagination caps the worst case at ~750 KB.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
page |
int | 1 |
1-indexed page number. Out-of-range values are clamped. |
per_page |
int | 100 |
Items per page. Clamped to [1, 500]. |
limit |
int | none | Legacy alias — if set and per_page was left at its default, it caps per_page (also clamped to [1, 500]). New callers should use page/per_page. |
timezone_offset |
int | 0 |
UTC offset in minutes |
provider |
repeated string | (none) | Scope to those providers. An active filter that excludes the project returns a shape-stable empty envelope. |
model |
repeated string | (none) | Scope by model id (filtered after the store read) |
Response — a paginated envelope.
{
"messages": [
{
"session_id": "020a13e5-ad60-41e5-9313-7cdf03cecf26",
"type": "user",
"timestamp": "2026-01-30T20:58:11.193Z",
"model": null,
"content": "What does this function do?",
"tools": [],
"tokens": {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0},
"cwd": "/Users/yadkonrad/dev/myproject",
"uuid": "…",
"parent_uuid": "…",
"is_sidechain": false,
"has_tool_result": false,
"error": false,
"is_interruption": false,
"message_id": null
}
],
"total": 11341,
"page": 1,
"per_page": 100,
"total_pages": 114,
"start_index": 0,
"end_index": 100
}type is the enricher's record kind — user, assistant, tool_result, summary, or compact_summary
(there is no role field). tokens is a nested dict. User-command messages also carry
interaction_tool_count, interaction_model, and interaction_assistant_steps.
Status codes: 200 success; 400 no project; 404 project not in store.
Counts and a per-type / per-model breakdown for the current project. No query parameters.
Response
{
"total": 11341,
"total_sessions": 20,
"by_type": {"user": 4381, "assistant": 6960},
"by_model": {
"claude-opus-4-6": 3566,
"claude-opus-4-7": 1455,
"null": 4381
},
"total_tokens": 3631252
}by_type keys are record kinds (user, assistant, tool_result, …); messages with no model land under
a null key. total_sessions is only present when the project has a project_mart row — that mart row
also supplies the total count; otherwise total is the in-memory message count and total_sessions is
absent. total_tokens sums input + output across every message.
Status codes: 200 success; 400 no project; 404 project not in store.
Runs an incremental ingest pass and updates the store. If a project is active, only that project is re-ingested. If no project is selected, all projects are refreshed.
Request body — a JSON object is required by the route signature (send {}); its contents are ignored.
Omitting the body entirely fails FastAPI validation with 422.
Response (project active)
{
"status": "success",
"message": "Files changed - data refreshed successfully",
"files_changed": true,
"message_count": 12,
"refresh_time_ms": 340
}Response (no project — all projects)
{
"status": "success",
"message": "Ingested 42 new records",
"files_changed": true,
"refresh_time_ms": 1820,
"projects_refreshed": 42,
"total_projects": 42
}Status codes: 200 always (errors are surfaced inside the JSON body or as 500).
The Cost tab loads its heavy attribution sections from a separate endpoint so the
initial dashboard payload stays small. All three endpoints accept an optional
log_path query parameter; when omitted they fall back to the project most recently
set via POST /api/project-by-dir.
Return only the analytics sections that were split off /api/dashboard-data.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
log_path |
string | current project | Override the active project |
timezone_offset |
int | 0 |
UTC offset in minutes for daily bucketing |
Response — an object keyed by the nine analytics sections. Missing sections
default to [] or {} (never null).
{
"session_costs": [{"session_id": "...", "cost": 4.21, "tokens": {}, "commands": 12, "errors": 0, "duration_s": 940}],
"command_costs": [{"interaction_id": "...", "session_id": "...", "timestamp": "...", "prompt_preview": "...", "cost": 0.18, "tokens": {}, "tools_used": 3, "steps": 2, "models_used": ["..."], "had_error": false}],
"tool_costs": {"Bash": {"calls": 120, "cost": 1.40}},
"token_composition": {"input": 24000, "output": 96000, "cache_read": 1200, "cache_write": 800},
"outliers": {"...": "..."},
"retry_signals": [{"...": "..."}],
"session_efficiency": [{"...": "..."}],
"error_cost": {"total": 0.42, "by_category": {}},
"trends": {"week_over_week": {}},
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}Status codes: 200 success; 400 no project selected and no log_path;
404 project not in store (run /api/refresh first).
Total cost, message count, and session count grouped by provider. Powers the Cost tab's "Cost by provider" card.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
period |
string | month |
One of today, week, month, all. Unknown values return 400. |
provider |
repeated string | (none) | Narrow the rows to these providers (case-insensitive; empty means all) |
Response
{
"period": "month",
"rows": [
{"provider": "claude", "cost_usd": 412.88, "message_count": 22310, "session_count": 184},
{"provider": "cursor", "cost_usd": 3.07, "message_count": 1035, "session_count": 23}
],
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}rows sort by cost descending. cost_usd is pre-converted to the active currency despite the field name
(it keeps the _usd suffix for backward compatibility with existing consumers). Unlike /api/cost-data
this route does not require a selected project — it rolls up the whole store.
Status codes: 200 success; 400 unknown period.
Paginated, sorted slice of the project's command list. Used by the Cost tab's "Most expensive commands" panel; clicking a row deep-links to the corresponding interaction in the Messages tab.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
log_path |
string | current project | Override the active project |
offset |
int | 0 |
Pagination offset (clamped to ≥ 0) |
limit |
int | 50 |
Page size (clamped to [1, 500]) |
sort |
string | cost |
One of cost, tokens, tools, steps, time. Unknown values fall back to cost. |
order |
string | desc |
desc or asc |
Response
{
"commands": [
{
"interaction_id": "abc123...",
"session_id": "020a13e5-...",
"timestamp": "2026-04-19T12:34:56Z",
"prompt_preview": "How do I refactor this function?",
"cost": 0.42,
"tokens": {"input": 1200, "output": 800, "cache_read": 0, "cache_write": 0},
"tools_used": 3,
"steps": 2,
"models_used": ["claude-opus-4-6"],
"had_error": false
}
],
"total": 482,
"offset": 0,
"limit": 50,
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}When the project has no enriched dataset the body is {"commands": [], "total": 0, "offset", "limit"}
(no currency key on that empty branch).
Status codes: 200 success; 400 no project selected and no log_path;
404 project not in store.
Return one enriched interaction (the originating user command + every assistant response + every tool result between them). Lets the Messages tab deep-link to a specific prompt without paging through the full message list.
Path parameter: interaction_id — the interaction_id from /api/commands.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
log_path |
string | current project | Override the active project |
Response — a JSON object describing the interaction:
{
"interaction_id": "abc123...",
"session_id": "020a13e5-...",
"start_time": "2026-04-19T12:34:56Z",
"end_time": "2026-04-19T12:35:48Z",
"model": "claude-opus-4-6",
"tool_count": 3,
"assistant_steps": 2,
"is_continuation": false,
"tools_used": ["Bash", "Read"],
"has_task_tool": false,
"command": {"kind": "user", "content": "...", "tokens": {}, "tools": [], "...": "..."},
"responses": [{"kind": "assistant", "content": "...", "tokens": {}, "...": "..."}],
"tool_results": [{"kind": "tool_result", "content": "...", "...": "..."}]
}Status codes: 200 success; 400 no project selected and no log_path;
404 project (or interaction) not found.
Every session (JSONL file) for a project, with per-session metadata and cost estimates.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
project |
string | current project | Log directory slug to query |
provider |
repeated string | (none) | Scope to those providers. If the project's provider is not in the set the response is {"files": [], "currency": {...}}. |
Response — an object with a files array (sorted by created ascending) and a currency block.
{
"files": [
{
"name": "020a13e5-ad60-41e5-9313-7cdf03cecf26.jsonl",
"path": "020a13e5-ad60-41e5-9313-7cdf03cecf26.jsonl",
"is_subagent": false,
"created": 1738274291.193,
"modified": 1738274291.193,
"size": 0,
"messages": 120,
"user_messages": 40,
"assistant_messages": 80,
"input_tokens": 24000,
"output_tokens": 96000,
"model": "claude-opus-4-6",
"title": "What does this function do?",
"tool_calls": 15,
"estimated_cost": 0.4812,
"provider": "claude"
}
],
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}is_subagent is true when the session id starts with agent-. provider carries the parent project's
provider name (claude, codex, cursor, cline, …); the React UI uses it to colour the per-session
chip. When the slug is not found in the store the route returns a bare empty array [] instead of the
object form.
Status codes: 200 success; 400 no project; 500 internal error.
Return the raw parsed messages for a single session, identified by filename.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
file |
string | yes | Filename, e.g. 020a13e5-....jsonl |
project |
string | no | Log dir slug (defaults to current project) |
Response
{
"lines": [{"type": "user", "message": {"role": "user", "content": "..."}}],
"total_lines": 120,
"user_count": 40,
"assistant_count": 80,
"metadata": {
"session_id": "020a13e5-ad60-41e5-9313-7cdf03cecf26",
"file_size": 0,
"created": 1738274291.193,
"modified": 1745116760.887,
"first_timestamp": "2026-01-30T20:58:11.193Z",
"last_timestamp": "2026-04-20T01:39:20.887Z",
"duration_minutes": 117.6,
"cwd": "/Users/yadkonrad/dev/myproject"
}
}Status codes: 200 success; 400 no project or invalid file param;
404 project or session not found in store; 500 internal error.
Side-by-side diff of two sessions, reusing the same session_costs rows the Cost
tab consumes so cost attribution stays consistent.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
a |
string | yes | Baseline session_id |
b |
string | yes | Comparison session_id |
log_path |
string | no | Override the active project |
Response
{
"a": {"session_id": "020a13e5-...", "cost": 4.21, "tokens": {}, "commands": 12, "errors": 0, "duration_s": 940},
"b": {"session_id": "7f72e05c-...", "cost": 5.83, "tokens": {}, "commands": 14, "errors": 1, "duration_s": 1120},
"diff": {"cost": 1.62, "tokens": {}, "commands": 2, "errors": 1, "duration_s": 180},
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}diff values are b - a. Token diffs are computed for the union of token keys
present in either side.
Status codes: 200 success; 400 no project selected and no log_path;
404 project or one/both session_ids not found; 500 internal error.
Full-text search across all indexed Claude Code sessions using SQLite FTS5.
Returns 503 if the search service failed to initialise on startup.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
q |
string | "" |
Search query |
project |
string | none | Filter to a specific project slug |
date_from |
string | none | ISO date YYYY-MM-DD (inclusive lower bound) |
date_to |
string | none | ISO date YYYY-MM-DD (inclusive upper bound) |
model |
string | none | Filter by model name |
role |
string | none | Filter by role (user or assistant) |
page |
int | 1 |
Result page (1-indexed) |
per_page |
int | 20 |
Results per page (max 100) |
Response
{
"results": [
{
"id": 238980,
"session_id": "7f72e05c-93a2-4221-9a3b-ce5648e0b433",
"project": "-Users-yadkonrad-dev-myproject",
"role": "assistant",
"content": "Here is the refactored version...",
"timestamp": "2026-03-23T20:58:44.155Z",
"model": "claude-opus-4-6",
"tokens_input": 1,
"tokens_output": 176,
"snippet": "...Here is the <mark>refactored</mark> version..."
}
],
"total": 233,
"page": 1,
"per_page": 20,
"total_pages": 12,
"query": "refactor"
}Status codes: 200 success; 503 search service unavailable; 500 query error.
Rebuild the full-text search index from all project data in the store. This is a blocking operation.
Request body — empty object {}.
Response
{
"projects_indexed": 98,
"total_messages_indexed": 82759,
"elapsed_ms": 4230.5
}Status codes: 200 success; 503 search service unavailable; 500 reindex error.
Return metadata about the search index — total message count, known models, and a per-project index log.
Response
{
"total_messages": 82759,
"total_projects": 98,
"models": ["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-7"],
"indexed_projects": [
{
"project": "-Users-yadkonrad-dev-myproject",
"indexed_at": "2026-04-02T02:48:28.332578+00:00",
"message_count": 13525
}
]
}Status codes: 200 success; 503 search service unavailable; 500 error.
List extracted Q&A pairs with filtering and pagination. A Q&A pair is a (user question, assistant answer)
tuple extracted from sessions. Returns 503 if the Q&A service failed to initialise.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
project |
string | none | Filter to a specific project slug |
date_from |
string | none | ISO date YYYY-MM-DD |
date_to |
string | none | ISO date YYYY-MM-DD |
search |
string | none | Text filter applied to question and answer text |
resolution_status |
string | none | One of resolved, looped, open |
page |
int | 1 |
Page number (1-indexed) |
per_page |
int | 20 |
Results per page (max 100) |
Response
{
"results": [
{
"id": "edaf6f427ec0a5de",
"session_id": "14b76974-d5c3-4430-b7e6-db7513bb1499",
"project": "-Users-yadkonrad-dev-myproject",
"question_text": "help me find this chrome tab plugin...",
"answer_text": "Let me search for the ObserverTab extension...",
"code_snippets": [],
"tools_used": [],
"timestamp": "2026-04-01T00:07:45.083Z",
"model": "claude-opus-4-6",
"num_attempts": 1,
"resolution_status": "open",
"loop_count": 0
}
],
"total": 4986,
"page": 1,
"per_page": 20,
"total_pages": 250
}Status codes: 200 success; 503 Q&A service unavailable; 500 error.
Fetch a single Q&A pair by its hex ID.
Path parameter: qa_id — the hex string ID from the id field in /api/qa results.
Response — same shape as a single element from /api/qa's results array.
Status codes: 200 success; 404 pair not found; 503 service unavailable; 500 error.
Rebuild the Q&A index by re-extracting pairs from all sessions in the store.
Request body — empty object {}.
Response
{
"projects_indexed": 98,
"total_qa_indexed": 4986,
"elapsed_ms": 8120.3
}Status codes: 200 success; 503 service unavailable; 500 error.
Return aggregate statistics about the Q&A index, including a per-project breakdown.
Response (abbreviated)
{
"total_pairs": 4986,
"by_project": [
{"project": "-Users-yadkonrad-dev-myproject", "count": 1310}
]
}Status codes: 200 success; 503 service unavailable; 500 error.
Return the full tag cloud — all tags with their session counts, category, and display colour.
Returns 503 if the tag service failed to initialise.
Response
{
"tags": [
{"name": "database", "count": 130, "category": "topic", "color": "#dd6b20"},
{"name": "python", "count": 78, "category": "language", "color": "#3572A5"},
{"name": "Bash", "count": 101, "category": "tool", "color": "#718096"}
],
"total_sessions": 144
}Tag category values observed: topic, language, framework, tool.
Status codes: 200 success; 503 service unavailable; 500 error.
List every session that carries a given tag.
Path parameter: tag — the tag name (e.g. python, debugging).
Response
{
"tag": "python",
"sessions": [
{"session_id": "020a13e5-...", "project": "-Users-yadkonrad-dev-myproject"}
],
"count": 78
}Status codes: 200 success; 503 service unavailable; 500 error.
Return all tags attached to a specific session.
Path parameter: session_id — UUID of the session.
Response — the shape is whatever the tag service returns; typically a list of tag names.
["python", "debugging", "fastapi"]Status codes: 200 success; 503 service unavailable; 500 error.
Manually add a tag to a session.
Path parameter: session_id — UUID of the session.
Request body
{"tag": "my-custom-tag"}Response — the result object returned by the tag service.
{"status": "added", "tag": "my-custom-tag", "session_id": "020a13e5-..."}Status codes: 200 success; 400 missing tag; 503 service unavailable; 500 error.
Remove a manually added tag from a session.
Path parameters: session_id (UUID), tag (tag name).
Response — the result object returned by the tag service.
{"status": "removed", "tag": "my-custom-tag", "session_id": "020a13e5-..."}Status codes: 200 success; 503 service unavailable; 500 error.
Rebuild auto-tags for all sessions across all projects.
Request body — empty object {}.
Response
{
"projects_indexed": 98,
"total_sessions_tagged": 144,
"elapsed_ms": 3100.8
}Status codes: 200 success; 503 service unavailable; 500 error.
List all bookmarks, optionally filtered by tag and sorted.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
tag |
string | none | Filter to bookmarks carrying this tag |
sort_by |
string | created_at |
Sort field |
Response
{
"bookmarks": [
{
"id": "bm_abc123",
"session_id": "020a13e5-...",
"title": "Great refactor session",
"notes": "Shows the clean pipeline pattern",
"tags": ["python", "refactoring"],
"message_index": 42,
"created_at": "2026-03-01T12:00:00Z",
"session_first_ts": "2026-03-01T09:00:00Z",
"session_last_ts": "2026-03-01T11:55:00Z",
"session_message_count": 120
}
]
}The session_first_ts, session_last_ts, and session_message_count fields are enriched from the session
store and may be absent if the session is not in the store.
Status codes: 200 success; 503 service unavailable; 500 error.
Create a new bookmark.
Request body
{
"session_id": "020a13e5-ad60-41e5-9313-7cdf03cecf26",
"title": "Great refactor session",
"message_index": 42,
"notes": "Shows the clean pipeline pattern",
"tags": ["python", "refactoring"]
}session_id is required. All other fields are optional (title defaults to "Untitled bookmark").
Response — the created bookmark object (same shape as one element in GET /api/bookmarks).
Status codes: 201 created; 400 missing session_id; 503 service unavailable; 500 error.
Update a bookmark's title, notes, and/or tags.
Path parameter: bookmark_id — the bookmark's ID string.
Request body — all fields optional; only provided fields are updated.
{
"title": "Updated title",
"notes": "New notes",
"tags": ["python"]
}Response — the updated bookmark object.
Status codes: 200 success; 404 not found; 503 service unavailable; 500 error.
Remove a bookmark by ID.
Response
{"status": "success", "message": "Bookmark removed"}Status codes: 200 success; 404 not found; 503 service unavailable; 500 error.
Add a bookmark if the session is not already bookmarked, or remove it if it is. Useful for a single-click "star" button.
Request body
{
"session_id": "020a13e5-ad60-41e5-9313-7cdf03cecf26",
"title": "Interesting session",
"message_index": 0
}session_id is required. title and message_index are optional.
Response — the result object returned by the bookmark service.
{"action": "added", "bookmark": {"id": "bm_abc123", "session_id": "020a13e5-..."}}or {"action": "removed", "bookmark_id": "bm_abc123"} when the bookmark existed.
Status codes: 200 success; 400 missing session_id; 503 service unavailable; 500 error.
List all bookmarks attached to a specific session.
Path parameter: session_id — UUID of the session.
Response
{"bookmarks": [{"id": "bm_abc123", "title": "Great session", "...": "..."}]}Status codes: 200 success; 503 service unavailable; 500 error.
These endpoints back the Settings page (/settings in the React UI) so the dashboard can
read and write the same persistent settings that the stackunderflow cfg CLI manipulates.
Writes go through the same Settings.persist machinery (validators run; the config file at
~/.stackunderflow/config.json is the single source of truth) and invalidate the dashboard
cache so the next /api/dashboard-data reflects the new values.
UI surfaces:
/settings— Currency dropdown (24 common ISO codes plus an "Other" text-input branch), Model aliases table (add/removefrom → topairs).- The dashboard tab bar — an Export button (top-right of every project tab) opens a
popover with format (CSV/JSON) and period (Today/7d/30d/All) controls; submit issues a
GET /api/export?...request and triggers a browser download.
Return all user-facing settings plus the currently active currency block.
Response
{
"settings": {
"currency": "EUR",
"model_aliases": {"openrouter/claude-opus": "claude-opus-4-6"},
"port": 8081,
"...": "..."
},
"currency": {"code": "EUR", "symbol": "€", "rate_from_usd": 0.93}
}Status codes: 200 always.
Return the suggested + locally cached currency codes the UI can render.
Response
{
"common": ["USD", "EUR", "GBP", "JPY", "..."],
"supported": ["USD", "AUD", "BGN", "..."],
"current": {"code": "EUR", "symbol": "€", "rate_from_usd": 0.93}
}common is the shortlist always shown in the dropdown. supported is whatever Frankfurter has
published locally (cached); the file may be empty until the first conversion fetch lands.
Status codes: 200 always.
Set the active currency. Body: {"code": "EUR"} (or {"currency": "EUR"} — both are accepted).
Codes are normalised to uppercase. Invalidates the dashboard cache so the next data fetch
reflects the new currency.
Response
{"currency": {"code": "EUR", "symbol": "€", "rate_from_usd": 0.93}}Status codes: 200 success; 400 if the code is not a 3-letter ISO 4217 string.
Return the current proxy → canonical alias map.
Response
{"aliases": {"openrouter/claude-opus": "claude-opus-4-6"}}Status codes: 200 always.
Add or update one alias. Body: {"from": "<proxy>", "to": "<canonical>"}. Both fields must be
non-empty strings.
Response
{"aliases": {"openrouter/claude-opus": "claude-opus-4-6", "...": "..."}}Status codes: 200 success; 400 if either field is empty / missing.
Remove one alias. The proxy id is passed as ?from=… (query parameter, not path) because
alias keys often contain slashes (openrouter/...) which would need double-encoding to round-trip
a path component.
Response
{"aliases": {"...": "..."}}Status codes: 200 success; 400 if from is empty / missing; 404 if the alias is not present.
Liveness and readiness check. Reports whether each optional service initialised successfully.
Response
{
"status": "ok",
"services": {
"search": true,
"tags": true,
"qa": true,
"bookmarks": true,
"pricing": true
}
}false for a service means it failed to start (the corresponding /api/<service>/* endpoints will
return 503).
Status codes: 200 always.
Return the current per-model pricing table. Data is fetched from LiteLLM and cached locally.
Response
{
"pricing": {
"claude-opus-4-6": {
"input_cost_per_token": 1.5e-05,
"output_cost_per_token": 7.5e-05,
"cache_creation_cost_per_token": 1.875e-05,
"cache_read_cost_per_token": 1.5e-06
}
},
"source": "litellm",
"timestamp": "2026-04-20T01:39:47.194182+00:00",
"is_stale": false
}Status codes: 200 success; 503 service unavailable; 500 error.
Force a re-fetch of pricing data from LiteLLM, bypassing the local cache.
Request body — empty object {}.
Response
{"status": "success", "message": "Pricing updated successfully"}Status codes: 200 success; 500 fetch failed or service error; 503 service unavailable.
Pass-through proxy to a local Ollama instance at http://localhost:11434/api/{path}. Accepts GET, POST,
PUT, and DELETE; the request body and most headers are forwarded verbatim, and chunked responses are
streamed back. This is what the meta-agent sidebar and any in-app Ollama feature talk to so the browser
never has to reach localhost:11434 directly.
Status codes: mirrors whatever Ollama returns; 502 with {"error": "Ollama not available"} when the
local instance is unreachable.
The static-asset routes GET /favicon.ico and GET /assets/{path} are also served by this module; they
return files, not JSON, and are not part of the data API.
This endpoint is the primary data source for the Overview page. The authoritative implementation is in
stackunderflow/store/queries.py:get_global_stats(). The config key is appended by the route handler.
| Field | Type | Description |
|---|---|---|
first_use_date |
string | ISO date YYYY-MM-DD of the earliest message in the store |
last_use_date |
string | ISO date YYYY-MM-DD of the most recent message |
daily_token_usage |
array | Per-day {date, input, output} token totals across all projects |
daily_costs |
array | Per-day {date, cost, by_model} cost rollup; by_model maps model name to cost float |
models |
object | Map of model name to {count, cost} across all time |
total_cache_read_tokens |
int | Sum of cache_read_tokens across every message in the store |
total_cache_write_tokens |
int | Sum of cache_create_tokens across every message in the store |
config |
object | Server config values surfaced to the UI; currently {max_date_range_days} |
daily_token_usage element
{"date": "2026-03-23", "input": 24000, "output": 96000}daily_costs element
{
"date": "2026-03-23",
"cost": 1.44,
"by_model": {
"claude-opus-4-6": 1.20,
"claude-sonnet-4-6": 0.24
}
}models entry
{
"claude-opus-4-6": {"count": 57584, "cost": 29402.30}
}count is the number of messages attributed to the model; cost is the cumulative USD cost computed
using the pricing table.
Stream a downloadable CSV or JSON file of cross-project usage data.
Same surface as the stackunderflow export CLI — both share a single
internal helper.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
format |
csv | json |
yes | Output format. |
period |
today | week | month | all |
no | Single window. Omit for a multi-period rollup (today + last 7 days + last 30 days) in one document. |
provider |
string | no | Filter by provider (claude, codex, etc.). |
project |
string (repeatable) | no | Include only this project slug. |
exclude |
string (repeatable) | no | Exclude this project slug. |
Response headers
Content-Type:text/csvorapplication/jsonContent-Disposition:attachment; filename="stackunderflow-export-<period>-<YYYY-MM-DD>.<ext>"X-Suggested-Filename: same filename, surfaced for fetch/blob clients that do not parseContent-Disposition.
Body
For format=csv the body is a multi-section CSV: one daily-rows block
plus one activity block per period. For format=json the body is a
period dict (with --period) or a {today, last_7d, last_30d} rollup
(without). See the CLI reference for the full schema.
Per-model side-by-side comparison over a window. Same surface as the stackunderflow compare CLI — both
call stackunderflow.services.compare.build_compare_payload.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
period |
string | month |
Window: today, week, month, or all. Unknown values return 400. |
project |
repeated string | (none) | Restrict to these project slugs |
provider |
string | (none) | Filter by provider id (claude, codex, cursor, …) |
Response
{
"period": "month",
"models": [
{
"model": "claude-opus-4-6",
"provider": "claude",
"sessions": 12,
"calls": 480,
"one_shot_pct": 0.167,
"retry_rate": 2.33,
"cache_hit_rate": 0.924,
"cost_per_call": 0.021,
"cost_per_session": 0.84,
"total_cost": 10.07,
"total_tokens": 4120000
}
],
"generated": 1746125443.117
}models sorts by total_cost descending; an empty store yields "models": []. generated is a Unix
epoch float. Per-row fields:
one_shot_pct— fraction of sessions where the user prompted once, the assistant answered once, and that was it (range 0.0–1.0).retry_rate—(assistant_messages / sessions) - 1, the average number of extra assistant turns per session.cache_hit_rate—cache_read / (cache_read + cache_create)(range 0.0–1.0).cost_per_call,cost_per_session,total_costare raw USD. This route does not pre-convert to the active currency and emits nocurrencyblock.
Status codes: 200 success; 400 unknown period.
Productive vs reverted vs abandoned breakdown for the sessions in a window. Same surface as
stackunderflow yield.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
period |
string | month |
One of today, week, month, all, 7days, 30days. Unknown values return 400. (week is an alias for 7days.) |
project |
repeated string | (none) | Filter to one or more project slugs |
Response
{
"period": "month",
"summary": {
"productive": 42,
"reverted": 3,
"abandoned": 18,
"no_repo": 5,
"total": 68,
"productive_cost": 124.55,
"reverted_cost": 9.12,
"abandoned_cost": 47.30,
"no_repo_cost": 0.42,
"total_cost": 181.39
},
"entries": [
{
"session_id": "ada0010e-f34f-4db5-9025-caa4a0db2b6a",
"project_slug": "-Users-you-dev-myproj",
"cwd": "/Users/you/dev/myproj",
"started_at": "2026-04-25T14:02:11+00:00",
"cost_usd": 12.43,
"classification": "productive",
"follow_commit_sha": "abc123def4567...",
"follow_commit_msg": "feat: add yield route",
"follow_commit_age_hours": 1.8
}
],
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0},
"warning": "Yield is correlated by time, not by content. ..."
}entries sorts by cost_usd descending. classification is one of productive, reverted,
abandoned, no_repo. The *_cost fields in summary and each entry's cost_usd are pre-converted to
the active currency (USD by default). The warning field always carries the heuristic caveat — frontend
consumers should render it inline so users read the breakdown as a smoke signal, not an audit.
Status codes: 200 success; 400 unknown period.
Heuristic warning. Yield correlates sessions with commits by time, not by content. A commit landing within 24h of a session start is credited to that session even if it was about something else. Multiple sessions in the same repo on the same day share follow-up commit attribution. See the CLI reference for the per-class definitions.
Active plan + current usage against budget + burn-projector v2 forecast.
Same payload as stackunderflow plan show --format json; see the CLI
reference's "Plan Budget Commands" section for the field-by-field
semantics. Status banding (ok / warn / over) is computed
identically to the CLI.
Query parameters — none.
Response (no plan configured)
{
"plan": null,
"usage": null,
"projection": null,
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}When no plan is set, plan / usage / projection are all null so
the frontend can render an "add a plan" CTA without parsing fields.
Response (plan configured)
{
"plan": {
"name": "claude-pro",
"monthly_usd": 20.0,
"reset_day": 1
},
"usage": {
"used": 12.50,
"budget": 20.00,
"remaining": 7.50,
"pct": 62.5,
"projected": 32.29,
"status": "ok",
"period_start": "2026-05-01",
"period_end": "2026-05-31",
"days_so_far": 12,
"days_in_period": 31
},
"projection": {
"projected_month_end_usd": 32.29,
"projection_method": "weighted-7d",
"daily_burn_usd": 1.04,
"days_to_limit": 7,
"thresholds": [50, 75, 90],
"crossed_threshold": 50,
"alert": "Crossed 50% of plan budget"
},
"currency": {"code": "USD", "symbol": "$", "rate_from_usd": 1.0}
}Field notes
plan.monthly_usd— canonical USD amount the user signed up for. Always in USD regardless of the active currency, because it's the user's contract amount.usage.used,usage.budget,usage.remaining,usage.projectedare pre-converted to the active currency viacurrency.rate_from_usd(same convention as/api/cost-dataand/api/yield).usage.pctis dimensionless and computed pre-conversion so the status banding (pct < 80 → ok,80 ≤ pct ≤ 100 → warn,pct > 100 → over) is identical across currencies.usage.projectedis the legacy linear extrapolation kept for backward compat; the burn-projector v2 number lives underprojection.projected_month_end_usd(which can use a smarter weighted-7d average — see below).projection.projection_methodis"weighted-7d"once the period has accumulated at least 3 non-zero daily samples (the weighted average decays at 0.85/day so recent activity dominates), else"linear". Auto-falls back to"linear"when the recent 7-day window collapses to $0 against an otherwise non-empty period (stale-store case) so the forecast doesn't silently zero out.projection.daily_burn_usdandprojection.projected_month_end_usdare pre-converted to the active currency, same as theusage.*fields.thresholds/crossed_threshold/days_to_limit/projection_methodare dimensionless.projection.crossed_thresholdis the highest configured threshold the user has met or exceeded (ornull).projection.alertis a human-readable banner string (ornull); the "projected to overrun" message supersedes a threshold-crossed message when the forecast exceeds the budget before the period ends.projection.thresholdsechoes the active alert ladder (default[50, 75, 90]; configurable viastackunderflow plan thresholds set).currencyis always present on both branches.
Status codes: 200 always.
Run waste-detection (legacy looped Q&A heuristic) and structural-pattern
findings (CLAUDE.md bloat, unused MCP, ghost agents, junk reads, cache
thrash, oversized bash output, exploration-only sessions) over a period.
Same surface as stackunderflow optimize.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
period |
string | 30days |
One of today, 7days, 30days, month, all. Unknown values return 400. |
project |
string (repeatable) | — | Narrow project scope to these slugs |
exclude |
string (repeatable) | — | Drop these project slugs |
force |
bool | false |
Bypass the in-process response cache for this call. The result is still written back to the cache. |
Response
{
"scope": "this month (May 2026)",
"waste": [
{
"project": "-Users-yadkonrad-dev-myproject",
"looped_pairs": 7,
"sample_questions": ["why did the test fail again", "..."]
}
],
"patterns": [
{
"pattern_id": "unused_mcp_servers",
"severity": "high",
"title": "6 unused MCP server(s)",
"description": "6 MCP server(s) registered but no tool calls observed in the last 30 days.",
"affected_count": 6,
"suggested_fix": "Remove unused MCP server entries from ~/.claude.json — every server adds tool definitions to each request's context.",
"estimated_waste_tokens": null,
"details": {
"unused_servers": ["apple-calendar", "tavily", "..."],
"registered_total": 6,
"lookback_days": 30
}
}
],
"warnings": [
{
"code": "mart_empty",
"level": "info",
"message": "message_tool_mart is empty — optimize detectors are running on the raw messages table and will be slower. Backfill via the ETL pipeline for the fast path."
}
],
"cache": "miss"
}Field notes (new)
warnings— list of advisory hints. Currently the only emitted code ismart_empty, raised whenmessage_tool_marthas no rows so the detectors are running the slow raw-messagesfallback path.cache—"hit"when the response came from the in-process cache,"miss"on a fresh compute. The cache is keyed on(period, project, exclude, store.db mtime)and self-invalidates whenever an ingest run bumps the store mtime;/api/refreshalso drops it eagerly.
Field notes
scope— human-readable label for the resolved period (e.g."this month (May 2026)","last 7 days","all time").waste— legacy Q&A loop heuristic. List of{project, looped_pairs, sample_questions}with at most 3 sample question strings each. Empty when no looped pairs are present.patterns— list ofFindingdicts emitted by the structural detectors instackunderflow/reports/optimize.py. Each finding has:pattern_id(one ofbloated_claude_md,unused_mcp_servers,ghost_agents,low_read_edit_ratio,junk_reads,cache_overhead,bash_output_limits),severity(high|medium|low),title,description,affected_count,suggested_fix,estimated_waste_tokens(int or null when not applicable), and adetailsdict whose keys vary by pattern. Sorted by severity desc, then byestimated_waste_tokensdesc.
Status codes: 200 success; 400 unknown period
(message lists the valid values).
Per-session "context tax" estimator — system prompt, registered MCP
servers, available skills, agent definitions, memory files. Same payload
as stackunderflow context-budget --format json. The estimator walks
visible config files defensively: any missing file contributes a
zero-token slice rather than raising.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
project |
string | no | Project slug (the url_slug from /api/projects). When omitted, the global budget (~/.claude only — system prompt, global CLAUDE.md, registered MCP servers, every ~/.claude/skills/*/SKILL.md) is returned. |
Response
{
"total_tokens": 19399,
"slices": [
{"name": "system_prompt", "tokens": 3000, "source_path": null},
{"name": "memory:global_CLAUDE.md", "tokens": 401, "source_path": "/Users/you/.claude/CLAUDE.md"},
{"name": "mcp:apple-calendar", "tokens": 400, "source_path": "/Users/you/.claude.json"},
{"name": "skill:anti-slop-guide", "tokens": 5856, "source_path": "/Users/you/.claude/skills/anti-slop-guide/SKILL.md"}
],
"cost_per_session_usd": 0.058197,
"estimated_monthly_cost_usd": 5.8197,
"heuristic": "len(text) // 4; per-MCP-server 200 + 50/tool"
}Field notes
total_tokens— sum oftokensacross every slice.slices— list ofContextSlicedicts.namefollows<kind>:<id>for kindsmcp,skill,agent,memory; the system prompt slice is namedsystem_promptwithsource_path: null.tokensislen(text) // 4(rounded down — seeheuristic).source_pathis the absolute path of the file that produced the slice, ornullfor synthetic slices.cost_per_session_usd—total_tokens × current Anthropic Sonnet input rate. USD; not pre-converted to the active currency.estimated_monthly_cost_usd—cost_per_session_usd × 100(a flat 100-sessions/mo assumption). USD.heuristic— the approximation string baked into every payload so consumers know the numbers are advisory.
When project points to a slug that exists in the store but whose
on-disk path is gone, the global-budget shape is returned as a
fallback (the project-CLAUDE.md slice silently contributes zero).
Status codes: 200 success; 404 unknown project slug
({"detail": "Unknown project slug: <slug>"}).
Live snapshot of the ETL pipeline — watcher state, mart watermarks vs
the max event id, per-provider event counts, and a coarse health enum
so the dashboard can render a status badge with one fetch.
Designed to be cheap (<50ms against a 200K-event store): every count
is a SELECT COUNT(*) on an indexed column, every per-mart watermark
is a primary-key lookup. Safe to poll from a UI status pill.
Query parameters
None.
Response
{
"watcher": {
"enabled": true,
"running": true,
"last_refresh_ts": "2026-05-06T08:24:11+00:00",
"seconds_since_refresh": 7,
"events_in_last_cycle": 12,
"lock_held_by": 48213
},
"marts": {
"daily": {"watermark": 228311, "row_count": 4521, "last_refresh_ts": "..."},
"session": {"watermark": 228311, "row_count": 1106, "last_refresh_ts": "..."},
"project": {"watermark": 228311, "row_count": 188, "last_refresh_ts": "..."},
"provider_day": {"watermark": 228311, "row_count": 312, "last_refresh_ts": "..."},
"model_day": {"watermark": 228311, "row_count": 482, "last_refresh_ts": "..."}
},
"events": {
"total": 228311,
"max_id": 228311,
"by_provider": {"claude": 225245, "codex": 1171, "cursor": 1035, "cline": 860},
"by_cost_source": {"rate_card": 226000, "estimated": 2311}
},
"lag_seconds": 0,
"health": "live",
"current_job": {
"job_id": "3f9a1c2e8b7d4a6f9e0c1b2a3d4e5f60",
"started_at": "2026-05-06T08:23:54+00:00",
"force": false,
"status": "running"
},
"last_job": null
}Field reference
| Field | Type | Description |
|---|---|---|
watcher.enabled |
bool | False iff STACKUNDERFLOW_DISABLE_WATCHER=1 is set in the environment |
watcher.running |
bool | "unknown" |
True iff the watcher's daemon thread is alive. "unknown" when the route is called outside the live FastAPI process (e.g. the CLI) — there's no way to introspect a thread that lives elsewhere |
watcher.last_refresh_ts |
string | null | ISO 8601 UTC timestamp of the most recent watcher cycle; null until the watcher publishes a refresh stamp |
watcher.seconds_since_refresh |
int | null | Seconds elapsed since last_refresh_ts. Drives the health=syncing rule |
watcher.events_in_last_cycle |
int | null | Number of events written in the most recent watcher cycle |
watcher.lock_held_by |
int | null | PID of the process currently holding the watcher lock; null when no lock is held or when the lock file is missing |
marts.<name>.watermark |
int | The last_event_id the mart has caught up to. 0 until the mart has refreshed once |
marts.<name>.row_count |
int | SELECT COUNT(*) against the underlying mart table |
marts.<name>.last_refresh_ts |
string | null | ISO 8601 UTC timestamp of the most recent mart refresh; null until the mart has refreshed once |
events.total |
int | COUNT(*) FROM usage_events |
events.max_id |
int | MAX(id) FROM usage_events; 0 on an empty store |
events.by_provider |
object | {provider: count} over every row in usage_events |
events.by_cost_source |
object | {cost_source: count} over every row in usage_events. The two values today are rate_card (priced from a published rate table) and estimated (priced from a heuristic when source data lacks per-token counts) |
lag_seconds |
int | max(0, max_event_id - min(mart watermarks)). The spec key is lag_seconds; the unit is "events behind", not seconds — kept under the spec key to match the wave-4C contract |
health |
enum | "live" (zero lag or empty store) / "syncing" (lag > 0 and a refresh in the last 10s) / "stale" (any mart > 100 events behind, watcher alive) / "error" (any mart > 100 events behind and watcher running=false, or the most recent backfill failed within its retention window) |
current_job |
object | null | The in-process backfill slot while a job runs: {job_id, started_at, force, status} (status is "running"); null when no backfill is running |
last_job |
object | null | The most recently finished backfill: {job_id, started_at, completed_at, force, status, error} where status is "complete" or "failed" and error is set only on failure; null until a backfill has finished |
The marts block has five entries — daily, session, project, provider_day, model_day. The store
holds additional mart tables (tool, command, message_tool), but the status route reports only these
five. current_job and last_job are in-process slots — they can change between back-to-back calls with no
database activity.
Status codes: 200 success on every call (the route never 4xx/5xxs
on a missing watcher or empty store — the response degrades gracefully
to running="unknown" and zero counts so a status pill in the UI is
never blocked by an in-flight bring-up).
Kick off an ETL backfill in the background. Equivalent to running
stackunderflow etl backfill from the CLI, but non-blocking — the route
returns immediately with a job identifier and the caller polls
GET /api/etl/status (current_job block) to track progress. Powers the
"Backfill now" button on the Settings page.
Request body — optional. {"force": true} requests a full rebuild; the default ({} or no body) is
an incremental backfill.
Response (202 Accepted — backfill scheduled)
{
"job_id": "3f9a1c2e8b7d4a6f9e0c1b2a3d4e5f60",
"started_at": "2026-05-06T08:23:54+00:00"
}Response (409 Conflict — a backfill is already running)
{
"error": "backfill_in_progress",
"job_id": "3f9a1c2e8b7d4a6f9e0c1b2a3d4e5f60"
}Status codes: 202 accepted; 409 a backfill is already in flight (the in-flight job_id is echoed in
the body so the caller can join the existing run instead of starting a new one).
The tool_count_distribution map — number of commands keyed by how
many tool calls each command made. Split off /api/dashboard-data
(spec §D2) so the Overview chart can lazy-fetch this section without
blocking initial paint. Mirrors the /api/cost-data pattern: same
canonical queries.get_project_stats call, projected to a single key.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
log_path |
string | current project | Override the active project (full log path, e.g. /Users/you/.claude/projects/<slug>) |
timezone_offset |
int | 0 |
Minutes offset from UTC for daily bucketing (forwarded to get_project_stats) |
Response
{
"tool_count_distribution": {
"0": 273,
"1": 111,
"3": 41,
"13": 7,
"31": 1
}
}Keys are the per-command tool count as a string ("0" = commands that
made no tool calls, "3" = commands that made exactly 3, etc.); values
are the number of commands with that exact tool count. Missing data
resolves to {"tool_count_distribution": {}} so the chart renders its
empty state rather than 500ing.
Status codes: 200 success; 400 no project selected and no
log_path provided; 404 project not in store
(run POST /api/refresh first).
The Playback tab on the dashboard scrubs through a session's tool calls in order. Two routes back the event-stream view; a third reconstructs the working filesystem at any timestamp.
Ordered tool-call event stream for one session.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
tool_filter |
string | null |
Comma-separated exact tool names (Edit,Write) to keep |
limit |
int | 1000 |
Clamped to [1, 10000] |
include_payload |
bool | true |
Include the ~200-char payload_excerpt on each event (empty string when false) |
Response
{
"session_id": "abc…",
"events": [
{
"seq": 0,
"ts": "2026-05-13T11:55:00Z",
"message_id": 1234,
"tool_name": "Edit",
"summary": "Edit src/main.py",
"target_path": "src/main.py",
"byte_count": 1280,
"success": true,
"duration_ms": 42,
"payload_excerpt": "…",
"session_id": "abc…"
}
],
"total": 1,
"truncated": false
}success is true / false / null (null when no authoritative tool-result flag was found).
truncated is true when the event count hit limit.
Returns 404 when the session id isn't in the store. Returns 200 with events: [] when the session exists
but issued no tool calls.
Cross-session timeline for one project. The top-level key is project_slug (not session_id); each event
carries its own session_id.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
since |
string | null |
Lower bound — no bound when omitted. Relative (7d, 24h, 90m) or an ISO-8601 timestamp. |
tool_filter |
string | null |
Comma-separated exact tool names |
limit |
int | 5000 |
Cap at 20000 |
include_payload |
bool | false |
Default off — project-wide streams are large |
Returns 404 when the slug isn't in the store.
Virtual-filesystem reconstruction at a point in time. Replays the
session's Read / Write / Edit / MultiEdit / NotebookEdit tool
calls up to at and returns the reconstructed content of every file
the session touched.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
at |
string | required | ISO-8601 timestamp; only operations with ts <= at are replayed |
paths |
string | null |
Comma-separated relative paths; restrict the response to these files |
include_content |
bool | true |
When false, omits the content field (file list + metadata only) |
Response
{
"session_id": "abc…",
"snapshot_ts": "2026-05-13T12:00:00Z",
"files": {
"src/main.py": {
"content": "def main():\n …\n",
"byte_count": 1234,
"last_modified_ts": "2026-05-13T11:55:00Z",
"operations_applied": ["Read#0", "Edit#0", "Edit#1"],
"reconstruction_complete": true,
"risk": {
"reverted_count": 2,
"failed_count": 0,
"worked_count": 9,
"total_sessions": 11
}
}
},
"warnings": [
"src/main.py: Edit#3 old_string did not match — substitution skipped"
]
}A risk block is attached to a file only when it has been reverted or failed at least once across the
store's history; files with a clean record have no risk key. include_content=false drops content
but keeps byte_count, the metadata, and risk.
Reconstruction semantics:
- Read seeds the initial content. The Claude Code
cat -n-style line-number prefix is stripped so subsequent Edits match the raw bytes. - Write replaces full content;
reconstruction_completebecomestruefrom this point regardless of prior state. - Edit / MultiEdit substitute
old_string → new_string. An Edit without a prior Read records thenew_stringas best-effort content withreconstruction_complete = falseand a warning. An Edit whoseold_stringdoesn't match the current content is skipped (warning fired, prior state preserved). - NotebookEdit accumulates a JSON
{cell_id: source}map keyed by cell id. Never marksreconstruction_complete = truebecause the full notebook tree is unobservable from edits alone. replace_allon Edit / per-MultiEdit sub-edit is honoured.
Status codes: 200 success (including the "session exists but issued no FS-touching tool calls" case → files: {}); 404 unknown session id; 422 unparseable at.
Read-only views over Claude Code parallel-agent topology — sessions that spawned sub-agents. Since store
migration v013, materialised ~/.claude/teams/ artefacts add spawn_prompt, agent_role, and team
description; on stores without those artefacts the service falls back to the is_sidechain heuristic and
those fields are null.
Recent sessions that spawned at least one sub-agent.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
limit |
int | 50 |
Clamped to [1, 500] |
project |
string | (none) | Restrict to teams whose lead session belongs to this project slug |
Response
{
"teams": [
{
"session_id": "020a13e5-…",
"project_slug": "-Users-you-dev-myproj",
"project_display_name": "myproj",
"team_name": "doc-overhaul",
"first_ts": "2026-05-18T09:00:00Z",
"last_ts": "2026-05-18T11:30:00Z",
"agent_count": 14,
"sub_agent_message_count": 5821,
"lead_message_count": 240,
"description": "Documentation overhaul"
}
]
}Empty stores return {"teams": []}.
Status codes: 200 always.
Dependency graph for one team, rooted at the lead session_id.
Response
{
"session_id": "020a13e5-…",
"team_name": "doc-overhaul",
"description": "Documentation overhaul",
"project_slug": "-Users-you-dev-myproj",
"project_display_name": "myproj",
"lead": {
"session_id": "020a13e5-…",
"agent_id": null,
"agent_name": null,
"is_lead": true,
"parent_session_id": null,
"message_count": 240,
"first_ts": "2026-05-18T09:00:00Z",
"last_ts": "2026-05-18T11:30:00Z",
"first_user_prompt": "Overhaul the docs…",
"model": "claude-opus-4-7",
"cost_usd": 12.40,
"spawn_prompt": null,
"agent_role": null
},
"agents": [
{"session_id": "agent-7f…", "agent_id": "1", "agent_name": "api-reference",
"is_lead": false, "parent_session_id": "020a13e5-…", "message_count": 410,
"first_ts": "…", "last_ts": "…", "first_user_prompt": "…",
"model": "claude-opus-4-7", "cost_usd": 3.18,
"spawn_prompt": "Overhaul docs/api-reference.md", "agent_role": "claude"}
]
}agents is empty when the session exists but spawned no sub-agents.
Status codes: 200 success; 404 lead session not found in store.
Full transcript for one agent within a team. session_id is the lead session; it acts as a same-project
fence so the URL cannot surface a cross-project session.
Response
{
"session_id": "020a13e5-…",
"agent_session_id": "agent-7f…",
"messages": [
{
"id": 91234, "seq": 0, "timestamp": "2026-05-18T09:05:00Z",
"role": "user", "model": null,
"input_tokens": 0, "output_tokens": 0,
"cache_create_tokens": 0, "cache_read_tokens": 0,
"content_text": "…", "tools_json": null, "raw_json": "{…}",
"is_sidechain": true, "uuid": "…", "parent_uuid": "…", "speed": "standard"
}
],
"message_count": 410
}Status codes: 200 success; 404 when the lead or agent session is missing, or the two sessions live
in different projects.
Endpoints behind the right-side meta-agent sidebar (shipped v0.8.0). The agent answers questions about the user's own sessions by calling backend tools that read the local SQLite store, driven by a local Ollama model — no remote network call.
The static tool catalogue the chat route hands the model on each turn.
Response
{
"tools": [{"type": "function", "function": {"name": "search_past_decisions", "...": "..."}}],
"names": ["search_past_decisions", "get_cost_summary", "get_file_risk", "..."],
"max_hops": 5
}tools is the OpenAI-style schema array (13 tools); names is the flat name list; max_hops is the
tool-call loop cap.
Status codes: 200 always.
Drive one user turn through the local model plus the tool catalogue. The response is not JSON — it is an
NDJSON stream (application/x-ndjson), one JSON object per line.
Request body
{
"messages": [{"role": "user", "content": "What did this project cost last week?"}],
"model": "qwen2.5-coder:7b",
"tools_enabled": true,
"project_slug": "-Users-you-dev-myproj"
}messages (non-empty list) and model (non-empty string) are required. tools_enabled defaults to
true; project_slug is optional and sets the default scope for "this project" questions.
Stream events — each line carries a type discriminator:
{"type": "token", "delta": "...", "ts": "..."}— a chunk of the final assistant message.{"type": "tool_call", "id": "...", "name": "...", "args": {...}, "ts": "..."}— the model requested a tool;idpairs with the result.{"type": "tool_result", "id": "...", "name": "...", "ok": bool, "data": {...}, "duration_ms": N, "ts": "..."}— execution finished.{"type": "error", "message": "...", "ts": "..."}— terminal; the loop bailed (Ollama down, hop cap hit).{"type": "done", "hops": N, "ts": "..."}— terminal; the model produced a content-only final turn.
Status codes: 200 and the stream opens (when Ollama is down the first stream line is an error
event); 400 for invalid JSON, an empty messages list, or a missing model.
Live-observability surface (Spec 13) for the dashboard's Live tab.
One-shot snapshot: rolling burn, per-tool latency percentiles, the current SSE watermarks, and watcher state. No query parameters.
Response
{
"burn": {
"window_minutes": 5,
"window_cost": 0.84,
"per_minute": 0.168,
"per_hour": 10.08,
"today_cost": 22.40,
"month_to_date": 311.90,
"projected_month_end": 540.12,
"ts": "2026-05-19T17:00:00+00:00"
},
"tool_latency": [
{"tool_name": "Bash", "samples": 412, "p50": 0.8, "p95": 4.2, "p99": 9.1}
],
"watermarks": {"event_id": 228311, "tool_call_id": 41020},
"watcher": {"running": true}
}tool_latency percentiles are in seconds, sorted by sample count, capped at the top 6 tools.
watcher.running is true / false / "unknown".
Status codes: 200 always.
Server-Sent Events stream for the Live tab. Returns text/event-stream; each message is a
event: <name>\ndata: <json>\n\n block.
Event names and payloads
ready— emitted once on connect:{type, ts, payload: {watermarks, watcher, burn_interval_seconds}}.event— a newusage_eventsrow:{type, ts, payload: {…row…}}.tool_call— a new tool-call row:{type, ts, payload: {…row…}}.burn_tick— emitted every 5s:{type, ts, payload: {…rolling burn…}}.
The store is polled every 2s; each cycle emits at most 100 rows per stream. The loop exits within ~100ms of a client disconnect.
Status codes: 200, then the stream stays open until the client disconnects.
Opt-in inbound receivers for PR / CI events. Each endpoint validates a signature header before parsing
the body, and reads its signing secret from an environment variable — never the database. If the secret env
var is unset the receiver returns 503 (opt-in by design; anonymous payloads are never accepted).
| Endpoint | Signature header | Secret env var |
|---|---|---|
POST /api/webhooks/github |
X-Hub-Signature-256 (HMAC-SHA256) |
STACKUNDERFLOW_GITHUB_WEBHOOK_SECRET |
POST /api/webhooks/gitlab |
X-Gitlab-Token (static-token compare) |
STACKUNDERFLOW_GITLAB_WEBHOOK_SECRET |
POST /api/webhooks/ci |
X-Webhook-Signature-256 (HMAC-SHA256) |
STACKUNDERFLOW_CI_WEBHOOK_SECRET |
All comparisons use hmac.compare_digest. github handles pull_request and workflow_run events (and
replies {"status": "pong"} to a ping); gitlab handles merge_request and pipeline; ci accepts a
generic workflow-run-shaped body.
Response — a small status object echoed back to the sender, for example:
{"status": "ok", "kind": "pr", "verb": "inserted", "pr_number": 412}An unrecognised event type is accepted with {"status": "ignored", ...} rather than failing.
Status codes: 200 accepted (including the ignored case); 400 invalid JSON or a payload missing
required fields; 403 missing or mismatched signature; 503 the signing secret env var is unset.